mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 05:51:12 -05:00
Persist spans to DB via logger
Implement fire-and-forget span persistence: - Add logSpanToDatabase and persistSpanToDatabase to logger - Persist spans, attributes, events, and event attributes to new tables - Wire edgeFunctionWrapper to call DB persistence after each span - Create required tables, views, and security policies - Ensure non-blocking and guard for missing Supabase creds
This commit is contained in:
@@ -15,8 +15,9 @@ import {
|
|||||||
endSpan,
|
endSpan,
|
||||||
addSpanEvent,
|
addSpanEvent,
|
||||||
logSpan,
|
logSpan,
|
||||||
|
logSpanToDatabase,
|
||||||
extractSpanContextFromHeaders,
|
extractSpanContextFromHeaders,
|
||||||
type Span
|
type Span
|
||||||
} from './logger.ts';
|
} from './logger.ts';
|
||||||
import { formatEdgeError, toError } from './errorFormatter.ts';
|
import { formatEdgeError, toError } from './errorFormatter.ts';
|
||||||
import { ValidationError, logValidationError } from './typeValidation.ts';
|
import { ValidationError, logValidationError } from './typeValidation.ts';
|
||||||
@@ -188,6 +189,7 @@ export function wrapEdgeFunction(
|
|||||||
|
|
||||||
endSpan(span, 'ok');
|
endSpan(span, 'ok');
|
||||||
logSpan(span);
|
logSpan(span);
|
||||||
|
logSpanToDatabase(span, requestId);
|
||||||
|
|
||||||
// Clone response to add tracking headers
|
// Clone response to add tracking headers
|
||||||
const responseBody = await response.text();
|
const responseBody = await response.text();
|
||||||
@@ -221,6 +223,7 @@ export function wrapEdgeFunction(
|
|||||||
logValidationError(error, requestId, name);
|
logValidationError(error, requestId, name);
|
||||||
endSpan(span, 'error', error);
|
endSpan(span, 'error', error);
|
||||||
logSpan(span);
|
logSpan(span);
|
||||||
|
logSpanToDatabase(span, requestId);
|
||||||
|
|
||||||
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
||||||
|
|
||||||
@@ -291,6 +294,7 @@ export function wrapEdgeFunction(
|
|||||||
|
|
||||||
endSpan(span, 'error', error);
|
endSpan(span, 'error', error);
|
||||||
logSpan(span);
|
logSpan(span);
|
||||||
|
logSpanToDatabase(span, requestId);
|
||||||
|
|
||||||
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
||||||
|
|
||||||
@@ -333,6 +337,7 @@ export function wrapEdgeFunction(
|
|||||||
|
|
||||||
endSpan(span, 'error', error);
|
endSpan(span, 'error', error);
|
||||||
logSpan(span);
|
logSpan(span);
|
||||||
|
logSpanToDatabase(span, requestId);
|
||||||
|
|
||||||
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Prevents sensitive data exposure and provides consistent log format
|
* Prevents sensitive data exposure and provides consistent log format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createClient } from 'jsr:@supabase/supabase-js@2';
|
||||||
import { formatEdgeError } from './errorFormatter.ts';
|
import { formatEdgeError } from './errorFormatter.ts';
|
||||||
|
|
||||||
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
||||||
@@ -178,7 +179,7 @@ export function injectSpanContextIntoHeaders(spanContext: SpanContext): Record<s
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log completed span
|
* Log completed span (console only)
|
||||||
*/
|
*/
|
||||||
export function logSpan(span: Span): void {
|
export function logSpan(span: Span): void {
|
||||||
const sanitizedAttributes = sanitizeContext(span.attributes);
|
const sanitizedAttributes = sanitizeContext(span.attributes);
|
||||||
@@ -196,6 +197,159 @@ export function logSpan(span: Span): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist span to database (fire-and-forget)
|
||||||
|
* Call this after logging the span to store it for monitoring/debugging
|
||||||
|
*/
|
||||||
|
export function logSpanToDatabase(span: Span, requestId?: string): void {
|
||||||
|
// Fire-and-forget - don't await or block on this
|
||||||
|
persistSpanToDatabase(span, requestId).catch((error) => {
|
||||||
|
edgeLogger.error('Failed to persist span to database', {
|
||||||
|
spanId: span.spanId,
|
||||||
|
traceId: span.traceId,
|
||||||
|
error: formatEdgeError(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal function to persist span to database
|
||||||
|
*/
|
||||||
|
async function persistSpanToDatabase(span: Span, requestId?: string): Promise<void> {
|
||||||
|
const supabaseUrl = Deno.env.get('SUPABASE_URL');
|
||||||
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseServiceKey) {
|
||||||
|
edgeLogger.warn('Skipping span persistence - Supabase credentials not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
// Insert span
|
||||||
|
const { error: spanError } = await supabase
|
||||||
|
.from('request_spans')
|
||||||
|
.insert({
|
||||||
|
span_id: span.spanId,
|
||||||
|
trace_id: span.traceId,
|
||||||
|
parent_span_id: span.parentSpanId,
|
||||||
|
request_id: requestId,
|
||||||
|
name: span.name,
|
||||||
|
kind: span.kind,
|
||||||
|
start_time: new Date(span.startTime).toISOString(),
|
||||||
|
end_time: span.endTime ? new Date(span.endTime).toISOString() : null,
|
||||||
|
duration_ms: span.duration,
|
||||||
|
status: span.status,
|
||||||
|
error_type: span.error?.type,
|
||||||
|
error_message: span.error?.message,
|
||||||
|
error_stack: span.error?.stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (spanError) {
|
||||||
|
throw new Error(`Failed to insert span: ${spanError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert attributes
|
||||||
|
const attributeInserts = Object.entries(span.attributes).map(([key, value]) => {
|
||||||
|
let valueType: 'string' | 'number' | 'boolean' = 'string';
|
||||||
|
let valueStr: string;
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
valueType = 'number';
|
||||||
|
valueStr = String(value);
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
valueType = 'boolean';
|
||||||
|
valueStr = String(value);
|
||||||
|
} else {
|
||||||
|
valueStr = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
span_id: span.spanId,
|
||||||
|
key,
|
||||||
|
value: valueStr,
|
||||||
|
value_type: valueType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attributeInserts.length > 0) {
|
||||||
|
const { error: attrError } = await supabase
|
||||||
|
.from('span_attributes')
|
||||||
|
.insert(attributeInserts);
|
||||||
|
|
||||||
|
if (attrError) {
|
||||||
|
edgeLogger.warn('Failed to insert span attributes', {
|
||||||
|
spanId: span.spanId,
|
||||||
|
error: attrError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert events
|
||||||
|
for (let i = 0; i < span.events.length; i++) {
|
||||||
|
const event = span.events[i];
|
||||||
|
|
||||||
|
const { data: eventData, error: eventError } = await supabase
|
||||||
|
.from('span_events')
|
||||||
|
.insert({
|
||||||
|
span_id: span.spanId,
|
||||||
|
timestamp: new Date(event.timestamp).toISOString(),
|
||||||
|
name: event.name,
|
||||||
|
sequence_order: i,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (eventError || !eventData) {
|
||||||
|
edgeLogger.warn('Failed to insert span event', {
|
||||||
|
spanId: span.spanId,
|
||||||
|
eventName: event.name,
|
||||||
|
error: eventError?.message,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert event attributes
|
||||||
|
if (event.attributes) {
|
||||||
|
const eventAttrInserts = Object.entries(event.attributes).map(([key, value]) => {
|
||||||
|
let valueType: 'string' | 'number' | 'boolean' = 'string';
|
||||||
|
let valueStr: string;
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
valueType = 'number';
|
||||||
|
valueStr = String(value);
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
valueType = 'boolean';
|
||||||
|
valueStr = String(value);
|
||||||
|
} else {
|
||||||
|
valueStr = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
span_event_id: eventData.id,
|
||||||
|
key,
|
||||||
|
value: valueStr,
|
||||||
|
value_type: valueType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (eventAttrInserts.length > 0) {
|
||||||
|
const { error: eventAttrError } = await supabase
|
||||||
|
.from('span_event_attributes')
|
||||||
|
.insert(eventAttrInserts);
|
||||||
|
|
||||||
|
if (eventAttrError) {
|
||||||
|
edgeLogger.warn('Failed to insert event attributes', {
|
||||||
|
spanId: span.spanId,
|
||||||
|
eventName: event.name,
|
||||||
|
error: eventAttrError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fields that should never be logged
|
// Fields that should never be logged
|
||||||
const SENSITIVE_FIELDS = [
|
const SENSITIVE_FIELDS = [
|
||||||
'password',
|
'password',
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- Fix security warnings from span storage schema
|
||||||
|
|
||||||
|
-- Drop and recreate views with SECURITY INVOKER
|
||||||
|
DROP VIEW IF EXISTS public.trace_summary;
|
||||||
|
DROP VIEW IF EXISTS public.span_hierarchy;
|
||||||
|
|
||||||
|
-- Recreate trace_summary with SECURITY INVOKER
|
||||||
|
CREATE VIEW public.trace_summary
|
||||||
|
WITH (security_invoker = true)
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
COUNT(*) as span_count,
|
||||||
|
MIN(start_time) as trace_start,
|
||||||
|
MAX(end_time) as trace_end,
|
||||||
|
SUM(duration_ms) as total_duration_ms,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'error') as error_count,
|
||||||
|
ARRAY_AGG(DISTINCT name) as span_names,
|
||||||
|
ARRAY_AGG(span_id ORDER BY start_time) as span_ids
|
||||||
|
FROM public.request_spans
|
||||||
|
GROUP BY trace_id;
|
||||||
|
|
||||||
|
-- Recreate span_hierarchy with SECURITY INVOKER
|
||||||
|
CREATE VIEW public.span_hierarchy
|
||||||
|
WITH (security_invoker = true)
|
||||||
|
AS
|
||||||
|
WITH RECURSIVE span_tree AS (
|
||||||
|
-- Root spans (no parent)
|
||||||
|
SELECT
|
||||||
|
span_id,
|
||||||
|
parent_span_id,
|
||||||
|
trace_id,
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
start_time,
|
||||||
|
duration_ms,
|
||||||
|
status,
|
||||||
|
1 as depth,
|
||||||
|
ARRAY[span_id] as path
|
||||||
|
FROM public.request_spans
|
||||||
|
WHERE parent_span_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Child spans
|
||||||
|
SELECT
|
||||||
|
rs.span_id,
|
||||||
|
rs.parent_span_id,
|
||||||
|
rs.trace_id,
|
||||||
|
rs.name,
|
||||||
|
rs.kind,
|
||||||
|
rs.start_time,
|
||||||
|
rs.duration_ms,
|
||||||
|
rs.status,
|
||||||
|
st.depth + 1,
|
||||||
|
st.path || rs.span_id
|
||||||
|
FROM public.request_spans rs
|
||||||
|
INNER JOIN span_tree st ON rs.parent_span_id = st.span_id
|
||||||
|
)
|
||||||
|
SELECT * FROM span_tree;
|
||||||
|
|
||||||
|
-- Recreate function with explicit search_path
|
||||||
|
CREATE OR REPLACE FUNCTION public.cleanup_old_spans()
|
||||||
|
RETURNS integer
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count integer;
|
||||||
|
BEGIN
|
||||||
|
-- Delete spans older than 30 days
|
||||||
|
DELETE FROM public.request_spans
|
||||||
|
WHERE created_at < now() - interval '30 days';
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user