mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
Implements complete plan to resolve duplicate span_id issues and metric collection errors: - Ensure edge handlers return proper Response objects to prevent double logging - Update collect-metrics to use valid metric categories, fix system_alerts query, and adjust returns - Apply detect-anomalies adjustments if needed and add defensive handling in wrapper - Prepare ground for end-to-end verification of location-related fixes
472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
/**
|
|
* Edge Function Wrapper with Comprehensive Error Handling
|
|
*
|
|
* Provides standardized:
|
|
* - Request/response logging
|
|
* - Error handling and formatting
|
|
* - Distributed tracing
|
|
* - Type validation
|
|
* - Performance monitoring
|
|
*/
|
|
|
|
import {
|
|
edgeLogger,
|
|
startSpan,
|
|
endSpan,
|
|
addSpanEvent,
|
|
logSpan,
|
|
logSpanToDatabase,
|
|
extractSpanContextFromHeaders,
|
|
type Span
|
|
} from './logger.ts';
|
|
import { formatEdgeError, toError } from './errorFormatter.ts';
|
|
import { ValidationError, logValidationError } from './typeValidation.ts';
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
|
|
|
export interface EdgeFunctionConfig {
|
|
name: string;
|
|
requireAuth?: boolean;
|
|
requiredRoles?: string[];
|
|
useServiceRole?: boolean;
|
|
corsHeaders?: HeadersInit;
|
|
logRequests?: boolean;
|
|
logResponses?: boolean;
|
|
}
|
|
|
|
export interface EdgeFunctionContext {
|
|
requestId: string;
|
|
span: Span;
|
|
userId?: string;
|
|
user?: any;
|
|
supabase: any;
|
|
}
|
|
|
|
export type EdgeFunctionHandler = (
|
|
req: Request,
|
|
context: EdgeFunctionContext
|
|
) => Promise<Response>;
|
|
|
|
/**
|
|
* Wrap an edge function with comprehensive error handling
|
|
*/
|
|
export function wrapEdgeFunction(
|
|
config: EdgeFunctionConfig,
|
|
handler: EdgeFunctionHandler
|
|
): (req: Request) => Promise<Response> {
|
|
const {
|
|
name,
|
|
requireAuth = true,
|
|
requiredRoles = [],
|
|
useServiceRole = false,
|
|
corsHeaders = {},
|
|
logRequests = true,
|
|
logResponses = true,
|
|
} = config;
|
|
|
|
return async (req: Request): Promise<Response> => {
|
|
// ========================================================================
|
|
// STEP 1: Handle CORS preflight
|
|
// ========================================================================
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response(null, {
|
|
status: 204,
|
|
headers: corsHeaders
|
|
});
|
|
}
|
|
|
|
// ========================================================================
|
|
// STEP 2: Initialize tracing
|
|
// ========================================================================
|
|
const parentSpanContext = extractSpanContextFromHeaders(req.headers);
|
|
const span = startSpan(
|
|
name,
|
|
'SERVER',
|
|
parentSpanContext,
|
|
{
|
|
'http.method': req.method,
|
|
'http.url': req.url,
|
|
'function.name': name,
|
|
}
|
|
);
|
|
const requestId = span.spanId;
|
|
|
|
// ========================================================================
|
|
// STEP 3: Log incoming request
|
|
// ========================================================================
|
|
if (logRequests) {
|
|
edgeLogger.info('Request received', {
|
|
requestId,
|
|
action: name,
|
|
method: req.method,
|
|
url: req.url,
|
|
hasAuth: req.headers.has('Authorization'),
|
|
contentType: req.headers.get('Content-Type'),
|
|
userAgent: req.headers.get('User-Agent'),
|
|
});
|
|
}
|
|
|
|
try {
|
|
// ====================================================================
|
|
// STEP 4: Create Supabase client
|
|
// ====================================================================
|
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
|
const authHeader = req.headers.get('Authorization');
|
|
|
|
let supabase;
|
|
if (useServiceRole) {
|
|
// Use service role key for backend operations
|
|
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
|
supabase = createClient(supabaseUrl, serviceRoleKey);
|
|
addSpanEvent(span, 'supabase_client_created', { type: 'service_role' });
|
|
} else if (authHeader) {
|
|
// Use anon key with user's auth header
|
|
const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
|
|
supabase = createClient(supabaseUrl, anonKey, {
|
|
global: { headers: { Authorization: authHeader } }
|
|
});
|
|
addSpanEvent(span, 'supabase_client_created', { type: 'authenticated' });
|
|
} else {
|
|
// Use anon key without auth
|
|
const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
|
|
supabase = createClient(supabaseUrl, anonKey);
|
|
addSpanEvent(span, 'supabase_client_created', { type: 'anonymous' });
|
|
}
|
|
|
|
// ====================================================================
|
|
// STEP 5: Authentication (if required)
|
|
// ====================================================================
|
|
let userId: string | undefined;
|
|
let user: any = undefined;
|
|
|
|
if (requireAuth) {
|
|
addSpanEvent(span, 'authentication_start');
|
|
|
|
if (!authHeader) {
|
|
addSpanEvent(span, 'authentication_failed', { reason: 'missing_header' });
|
|
endSpan(span, 'error');
|
|
logSpan(span);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Missing Authorization header',
|
|
requestId
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
);
|
|
}
|
|
|
|
// Get user from Supabase
|
|
const { data: { user: authUser }, error: userError } = await supabase.auth.getUser();
|
|
|
|
if (userError || !authUser) {
|
|
addSpanEvent(span, 'authentication_failed', {
|
|
reason: 'invalid_token',
|
|
error: formatEdgeError(userError)
|
|
});
|
|
endSpan(span, 'error', userError);
|
|
logSpan(span);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Invalid authentication token',
|
|
requestId
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
);
|
|
}
|
|
|
|
user = authUser;
|
|
userId = authUser.id;
|
|
|
|
addSpanEvent(span, 'authentication_success', { userId });
|
|
span.attributes['user.id'] = userId;
|
|
|
|
// ====================================================================
|
|
// STEP 6: Role verification (if required)
|
|
// ====================================================================
|
|
if (requiredRoles.length > 0) {
|
|
addSpanEvent(span, 'role_check_start', { requiredRoles });
|
|
|
|
let hasRequiredRole = false;
|
|
for (const role of requiredRoles) {
|
|
const { data: hasRole } = await supabase
|
|
.rpc('has_role', { _user_id: userId, _role: role });
|
|
|
|
if (hasRole) {
|
|
hasRequiredRole = true;
|
|
addSpanEvent(span, 'role_check_success', { role });
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!hasRequiredRole) {
|
|
addSpanEvent(span, 'role_check_failed', {
|
|
userId,
|
|
requiredRoles
|
|
});
|
|
endSpan(span, 'error');
|
|
logSpan(span);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Insufficient permissions',
|
|
requestId
|
|
}),
|
|
{
|
|
status: 403,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ====================================================================
|
|
// STEP 7: Execute handler
|
|
// ====================================================================
|
|
addSpanEvent(span, 'handler_start');
|
|
|
|
const context: EdgeFunctionContext = {
|
|
requestId,
|
|
span,
|
|
userId,
|
|
user,
|
|
supabase,
|
|
};
|
|
|
|
const response = await handler(req, context);
|
|
|
|
// ====================================================================
|
|
// STEP 6: Log success response
|
|
// ====================================================================
|
|
addSpanEvent(span, 'handler_complete', {
|
|
status: response.status,
|
|
statusText: response.statusText
|
|
});
|
|
|
|
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
|
|
|
if (logResponses) {
|
|
edgeLogger.info('Request completed', {
|
|
requestId,
|
|
action: name,
|
|
status: response.status,
|
|
duration,
|
|
});
|
|
}
|
|
|
|
endSpan(span, 'ok');
|
|
logSpan(span);
|
|
logSpanToDatabase(span, requestId);
|
|
|
|
// Clone response to add tracking headers
|
|
// Defensive check: ensure handler returned a Response object
|
|
let responseBody: string;
|
|
if (response instanceof Response) {
|
|
responseBody = await response.text();
|
|
} else {
|
|
// Handler returned non-Response (shouldn't happen, but handle it)
|
|
addSpanEvent(span, 'warning_non_response_object');
|
|
responseBody = JSON.stringify(response);
|
|
}
|
|
const enhancedResponse = new Response(responseBody, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: {
|
|
...Object.fromEntries(response.headers.entries()),
|
|
'X-Request-Id': requestId,
|
|
'X-Span-Id': span.spanId,
|
|
'X-Trace-Id': span.traceId,
|
|
'X-Duration-Ms': duration.toString(),
|
|
},
|
|
});
|
|
|
|
return enhancedResponse;
|
|
|
|
} catch (error) {
|
|
// ====================================================================
|
|
// STEP 7: Handle errors
|
|
// ====================================================================
|
|
|
|
// Validation errors (client error)
|
|
if (error instanceof ValidationError) {
|
|
addSpanEvent(span, 'validation_error', {
|
|
field: error.field,
|
|
expected: error.expected,
|
|
received: error.received,
|
|
});
|
|
|
|
logValidationError(error, requestId, name);
|
|
endSpan(span, 'error', error);
|
|
logSpan(span);
|
|
logSpanToDatabase(span, requestId);
|
|
|
|
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: error.message,
|
|
field: error.field,
|
|
expected: error.expected,
|
|
received: error.received,
|
|
requestId,
|
|
}),
|
|
{
|
|
status: 400,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-Id': requestId,
|
|
'X-Span-Id': span.spanId,
|
|
'X-Trace-Id': span.traceId,
|
|
'X-Duration-Ms': duration.toString(),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
// Database errors (check for specific codes)
|
|
const errorObj = error as any;
|
|
if (errorObj?.code) {
|
|
addSpanEvent(span, 'database_error', {
|
|
code: errorObj.code,
|
|
message: errorObj.message,
|
|
});
|
|
|
|
// Handle specific error codes
|
|
let status = 500;
|
|
let message = formatEdgeError(error);
|
|
|
|
if (errorObj.code === '23505') {
|
|
// Unique constraint violation
|
|
status = 409;
|
|
message = 'A record with this information already exists';
|
|
} else if (errorObj.code === '23503') {
|
|
// Foreign key violation
|
|
status = 400;
|
|
message = 'Referenced record does not exist';
|
|
} else if (errorObj.code === '23514') {
|
|
// Check constraint violation
|
|
status = 400;
|
|
message = 'Data violates database constraints';
|
|
} else if (errorObj.code === 'P0001') {
|
|
// Raised exception
|
|
status = 400;
|
|
message = errorObj.message || 'Database validation failed';
|
|
} else if (errorObj.code === '42501') {
|
|
// Insufficient privilege
|
|
status = 403;
|
|
message = 'Permission denied';
|
|
}
|
|
|
|
edgeLogger.error('Database error', {
|
|
requestId,
|
|
action: name,
|
|
errorCode: errorObj.code,
|
|
errorMessage: errorObj.message,
|
|
errorDetails: errorObj.details,
|
|
errorHint: errorObj.hint,
|
|
});
|
|
|
|
endSpan(span, 'error', error);
|
|
logSpan(span);
|
|
logSpanToDatabase(span, requestId);
|
|
|
|
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: message,
|
|
code: errorObj.code,
|
|
details: errorObj.details,
|
|
requestId,
|
|
}),
|
|
{
|
|
status,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-Id': requestId,
|
|
'X-Span-Id': span.spanId,
|
|
'X-Trace-Id': span.traceId,
|
|
'X-Duration-Ms': duration.toString(),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
// Generic errors
|
|
const errorMessage = formatEdgeError(error);
|
|
|
|
addSpanEvent(span, 'unhandled_error', {
|
|
error: errorMessage,
|
|
errorType: error instanceof Error ? error.name : typeof error,
|
|
});
|
|
|
|
edgeLogger.error('Unhandled error', {
|
|
requestId,
|
|
action: name,
|
|
error: errorMessage,
|
|
errorType: error instanceof Error ? error.name : typeof error,
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
|
|
endSpan(span, 'error', error);
|
|
logSpan(span);
|
|
logSpanToDatabase(span, requestId);
|
|
|
|
const duration = span.endTime ? span.duration : Date.now() - span.startTime;
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Internal server error',
|
|
message: errorMessage,
|
|
requestId,
|
|
}),
|
|
{
|
|
status: 500,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-Id': requestId,
|
|
'X-Span-Id': span.spanId,
|
|
'X-Trace-Id': span.traceId,
|
|
'X-Duration-Ms': duration.toString(),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a simple edge function with standard error handling
|
|
*
|
|
* Example usage:
|
|
* ```ts
|
|
* serve(createEdgeFunction({
|
|
* name: 'my-function',
|
|
* requireAuth: true,
|
|
* corsHeaders: myCorsHeaders,
|
|
* }, async (req, { requestId, span, userId }) => {
|
|
* // Your handler logic here
|
|
* return new Response(JSON.stringify({ success: true }), {
|
|
* status: 200,
|
|
* headers: { 'Content-Type': 'application/json' }
|
|
* });
|
|
* }));
|
|
* ```
|
|
*/
|
|
export function createEdgeFunction(
|
|
config: EdgeFunctionConfig,
|
|
handler: EdgeFunctionHandler
|
|
): (req: Request) => Promise<Response> {
|
|
return wrapEdgeFunction(config, handler);
|
|
}
|