Files
thrilltrack-explorer/src/lib/edgeFunctionTracking.ts
gpt-engineer-app[bot] 7642ac435b Connect to Lovable Cloud
Improve CORS handling and error logging to fix moderation edge cases:
- Add x-idempotency-key to allowed CORS headers and expose explicit POST methods
- Extend CORS headers to include Access-Control-Allow-Methods
- Update edge function tracing and client error handling to better detect and log CORS/network issues
- Enhance error handling utilities to surface CORS-related failures and provide clearer user messages
2025-11-11 02:39:15 +00:00

245 lines
7.5 KiB
TypeScript

/**
* Edge Function Request Tracking Wrapper
*
* Wraps Supabase function invocations with request tracking for debugging and monitoring.
* Provides correlation IDs for tracing requests across the system.
*/
import { supabase } from '@/lib/supabaseClient';
import { trackRequest } from './requestTracking';
import { getErrorMessage } from './errorHandler';
import { withRetry, isRetryableError, type RetryOptions } from './retryHelpers';
import { breadcrumb } from './errorBreadcrumbs';
import { logger } from './logger';
/**
* Invoke a Supabase edge function with request tracking
*
* @param functionName - Name of the edge function to invoke
* @param payload - Request payload
* @param userId - User ID for tracking (optional)
* @param parentRequestId - Parent request ID for chaining (optional)
* @param traceId - Trace ID for distributed tracing (optional)
* @param timeout - Request timeout in milliseconds (default: 30000)
* @param retryOptions - Optional retry configuration
* @param customHeaders - Custom headers to include in the request (e.g., X-Idempotency-Key)
* @returns Response data with requestId, status, and tracking info
*/
export async function invokeWithTracking<T = any>(
functionName: string,
payload: any = {},
userId?: string,
parentRequestId?: string,
traceId?: string,
timeout: number = 30000,
retryOptions?: Partial<RetryOptions>,
customHeaders?: Record<string, string>
): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number; status?: number; traceId?: string }> {
// Configure retry options with defaults
const effectiveRetryOptions: RetryOptions = {
maxAttempts: retryOptions?.maxAttempts ?? 3,
baseDelay: retryOptions?.baseDelay ?? 1000,
maxDelay: retryOptions?.maxDelay ?? 10000,
backoffMultiplier: retryOptions?.backoffMultiplier ?? 2,
jitter: true,
shouldRetry: isRetryableError,
onRetry: (attempt, error, delay) => {
// Log retry attempt to breadcrumbs
breadcrumb.apiCall(
`/functions/${functionName}`,
'POST',
undefined // status unknown during retry
);
console.info(`Retrying ${functionName} (attempt ${attempt}) after ${delay}ms:`,
getErrorMessage(error)
);
},
};
let attemptCount = 0;
try {
const { result, requestId, duration } = await trackRequest(
{
endpoint: `/functions/${functionName}`,
method: 'POST',
userId,
parentRequestId,
traceId,
},
async (context) => {
return await withRetry(
async () => {
attemptCount++;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// Generate W3C Trace Context header
const effectiveTraceId = context.traceId || crypto.randomUUID();
const spanId = crypto.randomUUID();
const traceparent = `00-${effectiveTraceId}-${spanId}-01`;
// Add breadcrumb with trace context
breadcrumb.apiCall(
`/functions/${functionName}`,
'POST',
undefined
);
try {
const { data, error } = await supabase.functions.invoke<T>(functionName, {
body: {
...payload,
clientRequestId: context.requestId,
traceId: effectiveTraceId,
},
signal: controller.signal,
headers: {
...customHeaders,
'traceparent': traceparent,
},
});
clearTimeout(timeoutId);
if (error) {
// Enhance error with status and context for retry logic
const enhancedError = new Error(error.message || 'Edge function error');
(enhancedError as any).status = error.status;
(enhancedError as any).context = error.context;
throw enhancedError;
}
return data;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
},
effectiveRetryOptions
);
}
);
return {
data: result,
error: null,
requestId,
duration,
attempts: attemptCount,
status: 200,
traceId,
};
} catch (error: unknown) {
// Handle AbortError specifically
if (error instanceof Error && error.name === 'AbortError') {
return {
data: null,
error: {
message: `Request timeout: ${functionName} took longer than ${timeout}ms to respond`,
code: 'TIMEOUT',
},
requestId: 'timeout',
duration: timeout,
attempts: attemptCount,
status: 408,
traceId: undefined,
};
}
const errorMessage = getErrorMessage(error);
// Detect CORS errors specifically
const isCorsError = errorMessage.toLowerCase().includes('cors') ||
errorMessage.toLowerCase().includes('cross-origin') ||
errorMessage.toLowerCase().includes('failed to send') ||
(error instanceof TypeError && errorMessage.toLowerCase().includes('failed to fetch'));
// Enhanced error logging
logger.error('[EdgeFunctionTracking] Edge function invocation failed', {
functionName,
error: errorMessage,
errorType: isCorsError ? 'CORS/Network' : (error as any)?.name || 'Unknown',
attempts: attemptCount,
isCorsError,
debugHint: isCorsError ? 'Browser blocked request - verify CORS headers allow X-Idempotency-Key or check network connectivity' : undefined,
status: (error as any)?.status,
});
return {
data: null,
error: {
message: errorMessage,
status: (error as any)?.status,
isCorsError,
},
requestId: 'unknown',
duration: 0,
attempts: attemptCount,
status: (error as any)?.status,
traceId: undefined,
};
}
}
/**
* Invoke multiple edge functions in parallel with batch tracking
*
* Uses a shared trace ID to correlate all operations.
*
* @param operations - Array of function invocation configurations
* @param userId - User ID for tracking
* @returns Array of results with their request IDs
*/
export async function invokeBatchWithTracking<T = any>(
operations: Array<{
functionName: string;
payload: any;
retryOptions?: Partial<RetryOptions>;
}>,
userId?: string
): Promise<
Array<{
functionName: string;
data: T | null;
error: any;
requestId: string;
duration: number;
attempts?: number;
status?: number;
}>
> {
const traceId = crypto.randomUUID();
const results = await Promise.allSettled(
operations.map(async (op) => {
const result = await invokeWithTracking<T>(
op.functionName,
op.payload,
userId,
undefined,
traceId,
30000,
op.retryOptions
);
return { functionName: op.functionName, ...result };
})
);
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
} else {
return {
functionName: operations[index].functionName,
data: null,
error: { message: result.reason?.message || 'Unknown error' },
requestId: 'unknown',
duration: 0,
};
}
});
}