/** * Structured logging utility for edge functions * Prevents sensitive data exposure and provides consistent log format */ import { createClient } from '@supabase/supabase-js'; import { formatEdgeError } from './errorFormatter.ts'; type LogLevel = 'info' | 'warn' | 'error' | 'debug'; interface LogContext { requestId?: string; // Correlation ID for tracing userId?: string; action?: string; duration?: number; // Request duration in ms traceId?: string; // Distributed tracing ID [key: string]: unknown; } // Span types for distributed tracing export interface Span { spanId: string; traceId: string; parentSpanId?: string; name: string; kind: 'SERVER' | 'CLIENT' | 'INTERNAL' | 'DATABASE'; startTime: number; endTime?: number; duration?: number; attributes: Record; events: SpanEvent[]; status: 'ok' | 'error' | 'unset'; error?: { type: string; message: string; stack?: string; }; } export interface SpanEvent { timestamp: number; name: string; attributes?: Record; } export interface SpanContext { traceId: string; spanId: string; traceFlags?: number; } // Request tracking utilities (legacy - use spans instead) export interface RequestTracking { requestId: string; start: number; traceId?: string; } export function startRequest(traceId?: string): RequestTracking { return { requestId: crypto.randomUUID(), start: Date.now(), traceId: traceId || crypto.randomUUID(), }; } export function endRequest(tracking: RequestTracking): number { return Date.now() - tracking.start; } // ============================================================================ // Span Lifecycle Functions // ============================================================================ /** * Start a new span */ export function startSpan( name: string, kind: Span['kind'], parentSpan?: SpanContext, attributes?: Record ): Span { const traceId = parentSpan?.traceId || crypto.randomUUID(); return { spanId: crypto.randomUUID(), traceId, parentSpanId: parentSpan?.spanId, name, kind, startTime: Date.now(), attributes: attributes || {}, events: [], status: 'unset', }; } /** * End a span with final status */ export function endSpan(span: Span, status?: 'ok' | 'error', error?: unknown): Span { span.endTime = Date.now(); span.duration = span.endTime - span.startTime; span.status = status || 'ok'; if (error) { const err = error instanceof Error ? error : new Error(formatEdgeError(error)); span.error = { type: err.name, message: err.message, stack: err.stack, }; } return span; } /** * Add event to span */ export function addSpanEvent( span: Span, name: string, attributes?: Record ): void { span.events.push({ timestamp: Date.now(), name, attributes, }); } /** * Set span attributes */ export function setSpanAttributes( span: Span, attributes: Record ): void { span.attributes = { ...span.attributes, ...attributes }; } /** * Extract span context for propagation */ export function getSpanContext(span: Span): SpanContext { return { traceId: span.traceId, spanId: span.spanId, }; } /** * Extract span context from HTTP headers (W3C Trace Context) */ export function extractSpanContextFromHeaders(headers: Headers): SpanContext | undefined { const traceparent = headers.get('traceparent'); if (!traceparent) return undefined; // Parse W3C traceparent: version-traceId-spanId-flags const parts = traceparent.split('-'); if (parts.length !== 4) return undefined; return { traceId: parts[1], spanId: parts[2], traceFlags: parseInt(parts[3], 16), }; } /** * Inject span context into headers */ export function injectSpanContextIntoHeaders(spanContext: SpanContext): Record { return { 'traceparent': `00-${spanContext.traceId}-${spanContext.spanId}-01`, }; } /** * Log completed span (console only) */ export function logSpan(span: Span): void { const sanitizedAttributes = sanitizeContext(span.attributes); const sanitizedEvents = span.events.map(e => ({ ...e, attributes: e.attributes ? sanitizeContext(e.attributes) : undefined, })); edgeLogger.info('Span completed', { span: { ...span, attributes: sanitizedAttributes, events: sanitizedEvents, }, }); } /** * 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 { 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 const SENSITIVE_FIELDS = [ 'password', 'token', 'secret', 'api_key', 'apikey', 'authorization', 'email', 'phone', 'ssn', 'credit_card', 'ip_address', 'session_id' ]; /** * Sanitize context to remove sensitive data */ export function sanitizeContext(context: LogContext): LogContext { const sanitized: LogContext = {}; for (const [key, value] of Object.entries(context)) { const lowerKey = key.toLowerCase(); // Skip sensitive fields if (SENSITIVE_FIELDS.some(field => lowerKey.includes(field))) { sanitized[key] = '[REDACTED]'; continue; } // Recursively sanitize objects if (value && typeof value === 'object' && !Array.isArray(value)) { sanitized[key] = sanitizeContext(value as LogContext); } else { sanitized[key] = value; } } return sanitized; } /** * Format log message with context */ function formatLog(level: LogLevel, message: string, context?: LogContext): string { const timestamp = new Date().toISOString(); const sanitizedContext = context ? sanitizeContext(context) : {}; return JSON.stringify({ timestamp, level, message, ...sanitizedContext }); } export const edgeLogger = { info: (message: string, context?: LogContext): void => { console.info(formatLog('info', message, context)); }, warn: (message: string, context?: LogContext): void => { console.warn(formatLog('warn', message, context)); }, error: (message: string, context?: LogContext): void => { console.error(formatLog('error', message, context)); }, debug: (message: string, context?: LogContext): void => { console.debug(formatLog('debug', message, context)); } };