mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
Update logger.ts to use the import-map resolved package - Replace jsr:@supabase/supabase-js@2 with @supabase/supabase-js - Align edge function logger with deno.json import map for reliable bundling
427 lines
10 KiB
TypeScript
427 lines
10 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
events: SpanEvent[];
|
|
status: 'ok' | 'error' | 'unset';
|
|
error?: {
|
|
type: string;
|
|
message: string;
|
|
stack?: string;
|
|
};
|
|
}
|
|
|
|
export interface SpanEvent {
|
|
timestamp: number;
|
|
name: string;
|
|
attributes?: Record<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown>
|
|
): 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<string, unknown>
|
|
): void {
|
|
span.events.push({
|
|
timestamp: Date.now(),
|
|
name,
|
|
attributes,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set span attributes
|
|
*/
|
|
export function setSpanAttributes(
|
|
span: Span,
|
|
attributes: Record<string, unknown>
|
|
): 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<string, string> {
|
|
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<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
|
|
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));
|
|
}
|
|
};
|