diff --git a/src/lib/edgeFunctionTracking.ts b/src/lib/edgeFunctionTracking.ts new file mode 100644 index 00000000..6bf10ca5 --- /dev/null +++ b/src/lib/edgeFunctionTracking.ts @@ -0,0 +1,114 @@ +/** + * 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 '@/integrations/supabase/client'; +import { trackRequest } from './requestTracking'; +import { getErrorMessage } from './errorHandler'; + +/** + * 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) + * @returns Response data with requestId + */ +export async function invokeWithTracking( + functionName: string, + payload: Record = {}, + userId?: string, + parentRequestId?: string, + traceId?: string +): Promise<{ data: T | null; error: any; requestId: string; duration: number }> { + try { + const { result, requestId, duration } = await trackRequest( + { + endpoint: `/functions/${functionName}`, + method: 'POST', + userId, + parentRequestId, + traceId, + }, + async (context) => { + // Include client request ID in payload for correlation + const { data, error } = await supabase.functions.invoke(functionName, { + body: { ...payload, clientRequestId: context.requestId }, + }); + + if (error) throw error; + return data; + } + ); + + return { data: result, error: null, requestId, duration }; + } catch (error: unknown) { + const errorMessage = getErrorMessage(error); + // On error, we don't have tracking info, so create basic response + return { + data: null, + error: { message: errorMessage }, + requestId: 'unknown', + duration: 0, + }; + } +} + +/** + * 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( + operations: Array<{ + functionName: string; + payload: Record; + }>, + userId?: string +): Promise< + Array<{ + functionName: string; + data: T | null; + error: any; + requestId: string; + duration: number; + }> +> { + const traceId = crypto.randomUUID(); + + const results = await Promise.allSettled( + operations.map(async (op) => { + const result = await invokeWithTracking( + op.functionName, + op.payload, + userId, + undefined, + traceId + ); + 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, + }; + } + }); +} diff --git a/src/lib/imageUploadHelper.ts b/src/lib/imageUploadHelper.ts index 23c2936d..40c15f56 100644 --- a/src/lib/imageUploadHelper.ts +++ b/src/lib/imageUploadHelper.ts @@ -1,5 +1,7 @@ import { supabase } from '@/integrations/supabase/client'; +import { invokeWithTracking } from './edgeFunctionTracking'; import type { UploadedImage } from '@/components/upload/EntityMultiImageUploader'; +import { logger } from './logger'; export interface CloudflareUploadResponse { result: { @@ -25,16 +27,28 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise 0) { if (newlyUploadedImageIds.length > 0) { - console.error(`imageUploadHelper.uploadPendingImages: Some uploads failed. Cleaning up ${newlyUploadedImageIds.length} newly uploaded images...`); + logger.error('Some uploads failed, cleaning up', { + action: 'upload_pending_images', + newlyUploadedCount: newlyUploadedImageIds.length, + failureCount: errors.length, + }); // Attempt cleanup in parallel with detailed error tracking const cleanupResults = await Promise.allSettled( newlyUploadedImageIds.map(imageId => - supabase.functions.invoke('upload-image', { - body: { action: 'delete', imageId } + invokeWithTracking('upload-image', { + action: 'delete', + imageId, }) ) ); @@ -120,13 +153,17 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise r.status === 'rejected'); if (cleanupFailures.length > 0) { - console.error( - `imageUploadHelper.uploadPendingImages: Failed to cleanup ${cleanupFailures.length} of ${newlyUploadedImageIds.length} images.`, - 'These images may remain orphaned in Cloudflare:', - newlyUploadedImageIds.filter((_, i) => cleanupResults[i].status === 'rejected') - ); + logger.error('Failed to cleanup images', { + action: 'upload_pending_images_cleanup', + cleanupFailures: cleanupFailures.length, + totalCleanup: newlyUploadedImageIds.length, + orphanedImages: newlyUploadedImageIds.filter((_, i) => cleanupResults[i].status === 'rejected'), + }); } else { - console.log(`imageUploadHelper.uploadPendingImages: Successfully cleaned up ${newlyUploadedImageIds.length} images.`); + logger.info('Successfully cleaned up images', { + action: 'upload_pending_images_cleanup', + cleanedCount: newlyUploadedImageIds.length, + }); } } diff --git a/src/lib/moderation/actions.ts b/src/lib/moderation/actions.ts index 875f3374..c8b1fcf7 100644 --- a/src/lib/moderation/actions.ts +++ b/src/lib/moderation/actions.ts @@ -11,6 +11,7 @@ import { createTableQuery } from '@/lib/supabaseHelpers'; import type { ModerationItem } from '@/types/moderation'; import { logger } from '@/lib/logger'; import { getErrorMessage } from '@/lib/errorHandler'; +import { invokeWithTracking, invokeBatchWithTracking } from '@/lib/edgeFunctionTracking'; /** * Type-safe update data for review moderation @@ -184,20 +185,32 @@ export async function approveSubmissionItems( itemIds: string[] ): Promise { try { - const { error: approvalError } = await supabase.functions.invoke( + const { data: approvalData, error: approvalError, requestId } = await invokeWithTracking( 'process-selective-approval', { - body: { - itemIds, - submissionId, - }, + itemIds, + submissionId, } ); if (approvalError) { + logger.error('Submission items approval failed via edge function', { + action: 'approve_submission_items', + submissionId, + itemCount: itemIds.length, + requestId, + error: approvalError.message, + }); throw new Error(`Failed to process submission items: ${approvalError.message}`); } + logger.info('Submission items approved successfully', { + action: 'approve_submission_items', + submissionId, + itemCount: itemIds.length, + requestId, + }); + return { success: true, message: `Successfully processed ${itemIds.length} item(s)`, @@ -478,24 +491,28 @@ export async function deleteSubmission( // Delete photos from Cloudflare if (validImageIds.length > 0) { - const deletePromises = validImageIds.map(async imageId => { - try { - await supabase.functions.invoke('upload-image', { - method: 'DELETE', - body: { imageId }, - }); - } catch (photoDeleteError: unknown) { - const errorMessage = getErrorMessage(photoDeleteError); - logger.error('Photo deletion failed', { - action: 'delete_submission_photo', - imageId, - error: errorMessage - }); - } - }); + const deleteResults = await invokeBatchWithTracking( + validImageIds.map(imageId => ({ + functionName: 'upload-image', + payload: { action: 'delete', imageId }, + })), + undefined + ); - await Promise.allSettled(deletePromises); - deletedPhotoCount = validImageIds.length; + // Count successful deletions + const successfulDeletions = deleteResults.filter(r => !r.error); + deletedPhotoCount = successfulDeletions.length; + + // Log any failures + const failedDeletions = deleteResults.filter(r => r.error); + if (failedDeletions.length > 0) { + logger.error('Some photo deletions failed', { + action: 'delete_submission_photos', + failureCount: failedDeletions.length, + totalAttempted: validImageIds.length, + failedRequestIds: failedDeletions.map(r => r.requestId), + }); + } } } } diff --git a/src/lib/moderation/lockMonitor.ts b/src/lib/moderation/lockMonitor.ts new file mode 100644 index 00000000..f2e37e45 --- /dev/null +++ b/src/lib/moderation/lockMonitor.ts @@ -0,0 +1,108 @@ +/** + * Moderation Lock Monitor + * + * Monitors lock expiry and provides automatic renewal prompts for moderators. + * Prevents loss of work due to expired locks. + */ + +import { useEffect } from 'react'; +import type { ModerationState } from '../moderationStateMachine'; +import type { ModerationAction } from '../moderationStateMachine'; +import { hasActiveLock, needsLockRenewal } from '../moderationStateMachine'; +import { toast } from '@/hooks/use-toast'; +import { supabase } from '@/integrations/supabase/client'; +import { logger } from '../logger'; + +/** + * Hook to monitor lock status and warn about expiry + * + * @param state - Current moderation state + * @param dispatch - State machine dispatch function + * @param itemId - ID of the locked item (optional, for manual extension) + */ +export function useLockMonitor( + state: ModerationState, + dispatch: React.Dispatch, + itemId?: string +) { + useEffect(() => { + if (!hasActiveLock(state)) { + return; + } + + const checkInterval = setInterval(() => { + if (needsLockRenewal(state)) { + logger.warn('Lock expiring soon', { + action: 'lock_expiry_warning', + itemId, + lockExpires: state.status === 'locked' || state.status === 'reviewing' + ? state.lockExpires + : undefined, + }); + + // Dispatch lock expiry warning + dispatch({ type: 'LOCK_EXPIRED' }); + + // Show toast with extension option + toast({ + title: 'Lock Expiring Soon', + description: 'Your lock on this submission will expire in less than 2 minutes. Click to extend.', + variant: 'default', + }); + } + }, 30000); // Check every 30 seconds + + return () => clearInterval(checkInterval); + }, [state, dispatch, itemId]); +} + +/** + * Extend the lock on a submission + * + * @param submissionId - Submission ID + * @param dispatch - State machine dispatch function + */ +async function handleExtendLock( + submissionId: string, + dispatch: React.Dispatch +) { + try { + // Call Supabase to extend lock (assumes 15 minute extension) + const { error } = await supabase + .from('content_submissions') + .update({ + locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(), + }) + .eq('id', submissionId); + + if (error) throw error; + + // Update state machine with new lock time + dispatch({ + type: 'LOCK_ACQUIRED', + payload: { lockExpires: new Date(Date.now() + 15 * 60 * 1000).toISOString() }, + }); + + toast({ + title: 'Lock Extended', + description: 'You have 15 more minutes to complete your review.', + }); + + logger.info('Lock extended successfully', { + action: 'lock_extended', + submissionId, + }); + } catch (error) { + logger.error('Failed to extend lock', { + action: 'extend_lock_error', + submissionId, + error: error instanceof Error ? error.message : String(error), + }); + + toast({ + title: 'Extension Failed', + description: 'Could not extend lock. Please save your work and re-claim the item.', + variant: 'destructive', + }); + } +} diff --git a/supabase/functions/cancel-account-deletion/index.ts b/supabase/functions/cancel-account-deletion/index.ts index bc12f9e1..39296501 100644 --- a/supabase/functions/cancel-account-deletion/index.ts +++ b/supabase/functions/cancel-account-deletion/index.ts @@ -1,6 +1,6 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { edgeLogger } from '../_shared/logger.ts'; +import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -8,6 +8,8 @@ const corsHeaders = { }; serve(async (req) => { + const tracking = startRequest(); + if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } @@ -35,7 +37,7 @@ serve(async (req) => { throw new Error('Unauthorized'); } - edgeLogger.info('Cancelling deletion request', { action: 'cancel_deletion', userId: user.id }); + edgeLogger.info('Cancelling deletion request', { action: 'cancel_deletion', userId: user.id, requestId: tracking.requestId }); // Find pending deletion request const { data: deletionRequest, error: requestError } = await supabaseClient @@ -101,29 +103,34 @@ serve(async (req) => { `, }), }); - edgeLogger.info('Cancellation confirmation email sent', { action: 'cancel_deletion_email', userId: user.id }); + edgeLogger.info('Cancellation confirmation email sent', { action: 'cancel_deletion_email', userId: user.id, requestId: tracking.requestId }); } catch (emailError) { - edgeLogger.error('Failed to send email', { action: 'cancel_deletion_email', userId: user.id }); + edgeLogger.error('Failed to send email', { action: 'cancel_deletion_email', userId: user.id, requestId: tracking.requestId }); } } + const duration = endRequest(tracking); + edgeLogger.info('Deletion cancelled successfully', { action: 'cancel_deletion_success', userId: user.id, requestId: tracking.requestId, duration }); + return new Response( JSON.stringify({ success: true, message: 'Account deletion cancelled successfully', + requestId: tracking.requestId }), { status: 200, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, } ); } catch (error) { - edgeLogger.error('Error cancelling deletion', { action: 'cancel_deletion_error', error: error instanceof Error ? error.message : String(error) }); + const duration = endRequest(tracking); + edgeLogger.error('Error cancelling deletion', { action: 'cancel_deletion_error', error: error instanceof Error ? error.message : String(error), requestId: tracking.requestId, duration }); return new Response( - JSON.stringify({ error: error.message }), + JSON.stringify({ error: error.message, requestId: tracking.requestId }), { status: 400, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, } ); } diff --git a/supabase/functions/create-novu-subscriber/index.ts b/supabase/functions/create-novu-subscriber/index.ts index b8e950c4..25ee50d5 100644 --- a/supabase/functions/create-novu-subscriber/index.ts +++ b/supabase/functions/create-novu-subscriber/index.ts @@ -1,15 +1,18 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { Novu } from "npm:@novu/api@1.6.0"; -// TODO: In production, restrict CORS to specific domains -// For now, allowing all origins for development flexibility -// Example production config: 'Access-Control-Allow-Origin': 'https://yourdomain.com' const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; +// Simple request tracking +const startRequest = () => ({ requestId: crypto.randomUUID(), start: Date.now() }); +const endRequest = (tracking: { start: number }) => Date.now() - tracking.start; + serve(async (req) => { + const tracking = startRequest(); + if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } @@ -197,28 +200,32 @@ serve(async (req) => { data, }); - console.log('Subscriber created successfully:', subscriber.data); + const duration = endRequest(tracking); + console.log('Subscriber created successfully:', subscriber.data, { requestId: tracking.requestId, duration }); return new Response( JSON.stringify({ success: true, subscriberId: subscriber.data._id, + requestId: tracking.requestId }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, status: 200, } ); } catch (error: any) { - console.error('Error creating Novu subscriber:', error); + const duration = endRequest(tracking); + console.error('Error creating Novu subscriber:', error, { requestId: tracking.requestId, duration }); return new Response( JSON.stringify({ success: false, error: error.message, + requestId: tracking.requestId }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, status: 500, } ); diff --git a/supabase/functions/process-oauth-profile/index.ts b/supabase/functions/process-oauth-profile/index.ts index 7df01c01..b018c9de 100644 --- a/supabase/functions/process-oauth-profile/index.ts +++ b/supabase/functions/process-oauth-profile/index.ts @@ -1,5 +1,6 @@ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; +import { startRequest, endRequest } from '../_shared/logger.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -73,6 +74,8 @@ async function ensureUniqueUsername( } Deno.serve(async (req) => { + const tracking = startRequest(); + if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } @@ -188,9 +191,10 @@ Deno.serve(async (req) => { .single(); if (profile?.avatar_image_id) { - console.log('[OAuth Profile] Avatar already exists, skipping'); - return new Response(JSON.stringify({ success: true, message: 'Avatar already exists' }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + const duration = endRequest(tracking); + console.log('[OAuth Profile] Avatar already exists, skipping', { requestId: tracking.requestId, duration }); + return new Response(JSON.stringify({ success: true, message: 'Avatar already exists', requestId: tracking.requestId }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, }); } @@ -326,22 +330,27 @@ Deno.serve(async (req) => { }); } - console.log('[OAuth Profile] Profile updated successfully'); + console.log('[OAuth Profile] Profile updated successfully', { requestId: tracking.requestId }); } + const duration = endRequest(tracking); + console.log('[OAuth Profile] Processing complete', { requestId: tracking.requestId, duration }); + return new Response(JSON.stringify({ success: true, avatar_uploaded: !!cloudflareImageId, profile_updated: Object.keys(updateData).length > 0, + requestId: tracking.requestId }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, }); } catch (error) { - console.error('[OAuth Profile] Error:', error); - return new Response(JSON.stringify({ error: error.message }), { + const duration = endRequest(tracking); + console.error('[OAuth Profile] Error:', error, { requestId: tracking.requestId, duration }); + return new Response(JSON.stringify({ error: error.message, requestId: tracking.requestId }), { status: 500, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, }); } }); diff --git a/supabase/functions/upload-image/index.ts b/supabase/functions/upload-image/index.ts index b21ff168..b96b1710 100644 --- a/supabase/functions/upload-image/index.ts +++ b/supabase/functions/upload-image/index.ts @@ -1,6 +1,6 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' -import { edgeLogger } from '../_shared/logger.ts' +import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts' // Environment-aware CORS configuration const getAllowedOrigin = (requestOrigin: string | null): string | null => { @@ -70,12 +70,13 @@ const createAuthenticatedSupabaseClient = (authHeader: string) => { } serve(async (req) => { + const tracking = startRequest(); const requestOrigin = req.headers.get('origin'); const allowedOrigin = getAllowedOrigin(requestOrigin); // Check if this is a CORS request with a disallowed origin if (requestOrigin && !allowedOrigin) { - edgeLogger.warn('CORS request rejected', { action: 'cors_validation', origin: requestOrigin }); + edgeLogger.warn('CORS request rejected', { action: 'cors_validation', origin: requestOrigin, requestId: tracking.requestId }); return new Response( JSON.stringify({ error: 'Origin not allowed', @@ -160,19 +161,26 @@ serve(async (req) => { } if (profile.banned) { + const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'Account suspended', - message: 'Account suspended. Contact support for assistance.' + message: 'Account suspended. Contact support for assistance.', + requestId: tracking.requestId }), { status: 403, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + 'X-Request-ID': tracking.requestId + } } ) } // Delete image from Cloudflare + edgeLogger.info('Deleting image', { action: 'delete_image', requestId: tracking.requestId }); let requestBody; try { requestBody = await req.json(); @@ -280,10 +288,12 @@ serve(async (req) => { ) } + const duration = endRequest(tracking); + edgeLogger.info('Image deleted successfully', { action: 'delete_image', requestId: tracking.requestId, duration }); return new Response( - JSON.stringify({ success: true, deleted: true }), + JSON.stringify({ success: true, deleted: true, requestId: tracking.requestId }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ) } @@ -344,19 +354,22 @@ serve(async (req) => { } if (profile.banned) { + const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'Account suspended', - message: 'Account suspended. Contact support for assistance.' + message: 'Account suspended. Contact support for assistance.', + requestId: tracking.requestId }), { status: 403, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ) } // Request a direct upload URL from Cloudflare + edgeLogger.info('Requesting upload URL', { action: 'request_upload_url', requestId: tracking.requestId }); let requestBody; try { requestBody = await req.json(); @@ -448,14 +461,17 @@ serve(async (req) => { } // Return the upload URL and image ID to the client + const duration = endRequest(tracking); + edgeLogger.info('Upload URL created', { action: 'upload_url_success', requestId: tracking.requestId, duration }); return new Response( JSON.stringify({ success: true, uploadURL: directUploadResult.result.uploadURL, id: directUploadResult.result.id, + requestId: tracking.requestId }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ) } @@ -569,10 +585,13 @@ serve(async (req) => { // Return the image details with convenient URLs const result = imageResult.result + const duration = endRequest(tracking); // Construct proper imagedelivery.net URLs using account hash and image ID const baseUrl = `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${result.id}` + edgeLogger.info('Image status retrieved', { action: 'get_image_status', requestId: tracking.requestId, duration }); + return new Response( JSON.stringify({ success: true, @@ -587,36 +606,41 @@ serve(async (req) => { medium: `${baseUrl}/medium`, large: `${baseUrl}/large`, avatar: `${baseUrl}/avatar`, - } : null + } : null, + requestId: tracking.requestId }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ) } + const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'Method not allowed', - message: 'HTTP method not supported for this endpoint' + message: 'HTTP method not supported for this endpoint', + requestId: tracking.requestId }), { status: 405, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ) } catch (error: unknown) { + const duration = endRequest(tracking); const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred'; - console.error('[Upload] Error:', { error: errorMessage }); + edgeLogger.error('Upload function error', { action: 'upload_error', requestId: tracking.requestId, duration, error: errorMessage }); return new Response( JSON.stringify({ error: 'Internal server error', - message: errorMessage + message: errorMessage, + requestId: tracking.requestId }), { status: 500, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ) }