diff --git a/.changeset/sour-comics-stare.md b/.changeset/sour-comics-stare.md new file mode 100644 index 0000000000..4a2d465007 --- /dev/null +++ b/.changeset/sour-comics-stare.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': patch +'@clerk/shared': patch +--- + +Add clerkTraceId to ClerkBackendApiResponse and ClerkAPIResponseError to allow for better tracing and debugging API error responses. +Uses `clerk_trace_id` when available in a response and defaults to [`cf-ray` identifier](https://developers.cloudflare.com/fundamentals/reference/cloudflare-ray-id/) if missing. diff --git a/packages/backend/src/api/factory.test.ts b/packages/backend/src/api/factory.test.ts index 04affe51e5..a7d373569e 100644 --- a/packages/backend/src/api/factory.test.ts +++ b/packages/backend/src/api/factory.test.ts @@ -4,7 +4,7 @@ import sinon from 'sinon'; import emailJson from '../fixtures/responses/email.json'; import userJson from '../fixtures/responses/user.json'; import runtime from '../runtime'; -import { jsonNotOk, jsonOk } from '../util/mockFetch'; +import { jsonError, jsonNotOk, jsonOk } from '../util/mockFetch'; import { createBackendApiClient } from './factory'; export default (QUnit: QUnit) => { @@ -151,12 +151,14 @@ export default (QUnit: QUnit) => { test('executes a failed backend API request and parses the error response', async assert => { const mockErrorPayload = { code: 'whatever_error', message: 'whatever error', meta: {} }; + const traceId = 'trace_id_123'; fakeFetch = sinon.stub(runtime, 'fetch'); - fakeFetch.onCall(0).returns(jsonNotOk({ errors: [mockErrorPayload] })); + fakeFetch.onCall(0).returns(jsonNotOk({ errors: [mockErrorPayload], clerk_trace_id: traceId })); try { await apiClient.users.getUser('user_deadbeef'); } catch (e: any) { + assert.equal(e.clerkTraceId, traceId); assert.equal(e.clerkError, true); assert.equal(e.status, 422); assert.equal(e.errors[0].code, 'whatever_error'); @@ -174,6 +176,30 @@ export default (QUnit: QUnit) => { ); }); + test('executes a failed backend API request and include cf ray id when trace not present', async assert => { + fakeFetch = sinon.stub(runtime, 'fetch'); + fakeFetch.onCall(0).returns(jsonError({ errors: [] })); + + try { + await apiClient.users.getUser('user_deadbeef'); + } catch (e: any) { + assert.equal(e.clerkError, true); + assert.equal(e.status, 500); + assert.equal(e.clerkTraceId, 'mock_cf_ray'); + } + + assert.ok( + fakeFetch.calledOnceWith('https://api.clerk.test/v1/users/user_deadbeef', { + method: 'GET', + headers: { + Authorization: 'Bearer deadbeef', + 'Content-Type': 'application/json', + 'Clerk-Backend-SDK': '@clerk/backend', + }, + }), + ); + }); + test('executes a successful backend API request to delete a domain', async assert => { const domainId = 'dmn_123'; const fakeResponse = { diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index fee69a3948..0cadfd69a2 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -1,3 +1,4 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; import type { ClerkAPIError, ClerkAPIErrorJSON } from '@clerk/types'; import deepmerge from 'deepmerge'; import snakecaseKeys from 'snakecase-keys'; @@ -37,6 +38,7 @@ export type ClerkBackendApiResponse = | { data: null; errors: ClerkAPIError[]; + clerkTraceId?: string; }; export type RequestFunction = ReturnType; @@ -52,13 +54,14 @@ const withLegacyReturn = (cb: any): LegacyRequestFunction => async (...args) => { // @ts-ignore - const { data, errors, status, statusText } = await cb(...args); + const { data, errors, status, statusText, clerkTraceId } = await cb(...args); if (errors === null) { return data; } else { throw new ClerkAPIResponseError(statusText || '', { data: errors, status: status || '', + clerkTraceId, }); } }; @@ -161,6 +164,7 @@ export function buildRequest(options: CreateBackendApiOptions) { message: err.message || 'Unexpected error', }, ], + clerkTraceId: getTraceId(err, res?.headers), }; } @@ -171,6 +175,7 @@ export function buildRequest(options: CreateBackendApiOptions) { // @ts-expect-error status: res?.status, statusText: res?.statusText, + clerkTraceId: getTraceId(err, res?.headers), }; } }; @@ -178,6 +183,17 @@ export function buildRequest(options: CreateBackendApiOptions) { return withLegacyReturn(request); } +// Returns either clerk_trace_id if present in response json, otherwise defaults to CF-Ray header +// If the request failed before receiving a response, returns undefined +function getTraceId(data: unknown, headers?: Headers): string { + if (data && typeof data === 'object' && 'clerk_trace_id' in data && typeof data.clerk_trace_id === 'string') { + return data.clerk_trace_id; + } + + const cfRay = headers?.get('cf-ray'); + return cfRay || ''; +} + function parseErrors(data: unknown): ClerkAPIError[] { if (!!data && typeof data === 'object' && 'errors' in data) { const errors = data.errors as ClerkAPIErrorJSON[]; @@ -197,23 +213,3 @@ function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { }, }; } - -class ClerkAPIResponseError extends Error { - clerkError: true; - - status: number; - message: string; - - errors: ClerkAPIError[]; - - constructor(message: string, { data, status }: { data: ClerkAPIError[]; status: number }) { - super(message); - - Object.setPrototypeOf(this, ClerkAPIResponseError.prototype); - - this.clerkError = true; - this.message = message; - this.status = status; - this.errors = data; - } -} diff --git a/packages/backend/src/util/mockFetch.ts b/packages/backend/src/util/mockFetch.ts index 387c18cbd4..89cf9f5e68 100644 --- a/packages/backend/src/util/mockFetch.ts +++ b/packages/backend/src/util/mockFetch.ts @@ -42,4 +42,14 @@ export function jsonError(body: unknown, status = 500) { return Promise.resolve(mockResponse); } -const mockHeadersGet = (key: string) => (key === constants.Headers.ContentType ? constants.ContentTypes.Json : null); +const mockHeadersGet = (key: string) => { + if (key === constants.Headers.ContentType) { + return constants.ContentTypes.Json; + } + + if (key === 'cf-ray') { + return 'mock_cf_ray'; + } + + return null; +}; diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 1c6539b123..38cb1927d3 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -22,6 +22,7 @@ export function isNetworkError(e: any): boolean { interface ClerkAPIResponseOptions { data: ClerkAPIErrorJSON[]; status: number; + clerkTraceId?: string; } // For a comprehensive Metamask error list, please see @@ -88,24 +89,32 @@ export class ClerkAPIResponseError extends Error { status: number; message: string; + clerkTraceId?: string; errors: ClerkAPIError[]; - constructor(message: string, { data, status }: ClerkAPIResponseOptions) { + constructor(message: string, { data, status, clerkTraceId }: ClerkAPIResponseOptions) { super(message); Object.setPrototypeOf(this, ClerkAPIResponseError.prototype); this.status = status; this.message = message; + this.clerkTraceId = clerkTraceId; this.clerkError = true; this.errors = parseErrors(data); } public toString = () => { - return `[${this.name}]\nMessage:${this.message}\nStatus:${this.status}\nSerialized errors: ${this.errors.map(e => - JSON.stringify(e), + let message = `[${this.name}]\nMessage:${this.message}\nStatus:${this.status}\nSerialized errors: ${this.errors.map( + e => JSON.stringify(e), )}`; + + if (this.clerkTraceId) { + message += `\nClerk Trace ID: ${this.clerkTraceId}`; + } + + return message; }; }