Skip to content

Commit 158a006

Browse files
committed
browser-sdk: include API error details in non-retriable bulk logs
1 parent 3c0e5f7 commit 158a006

2 files changed

Lines changed: 108 additions & 7 deletions

File tree

packages/browser-sdk/src/bulkQueue.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const WARN_AFTER_CONSECUTIVE_FAILURES = 10;
1111
const WARN_AFTER_FAILURE_MS = 5 * 60 * 1000;
1212
const WARN_THROTTLE_MS = 15 * 60 * 1000;
1313
const DROP_ERROR_THROTTLE_MS = 15 * 60 * 1000;
14+
const MAX_RESPONSE_BODY_PREVIEW_CHARS = 500;
1415

1516
type PayloadContext = {
1617
active?: boolean;
@@ -66,6 +67,12 @@ export type BulkQueueOptions = {
6667
logger?: Logger;
6768
};
6869

70+
type BulkErrorDetails = {
71+
responseBody?: string;
72+
apiErrorCode?: string;
73+
apiErrorMessage?: string;
74+
};
75+
6976
function getSessionStorage(): Storage | null {
7077
try {
7178
if (typeof sessionStorage === "undefined") {
@@ -248,17 +255,20 @@ export class BulkQueue {
248255
const res = await this.sendBulk(batch);
249256
if (!res.ok) {
250257
if (res.status >= 400 && res.status < 500) {
251-
const responseBody = await this.getResponseBodyPreview(res);
258+
const errorDetails = await this.getResponseErrorDetails(res);
259+
const errorSummary = this.getApiErrorSummary(errorDetails);
252260
this.retryCount = 0;
253261
this.firstFailureAt = null;
254262
this.consecutiveFailures = 0;
255263
this.lastWarnAt = null;
256264
this.logger?.error(
257-
"bulk request failed with non-retriable status; dropping batch",
265+
errorSummary
266+
? `bulk request failed with non-retriable status; dropping batch: ${errorSummary}`
267+
: "bulk request failed with non-retriable status; dropping batch",
258268
{
259269
status: res.status,
260270
statusText: res.statusText,
261-
responseBody,
271+
...errorDetails,
262272
},
263273
);
264274
nextDelayMs = this.flushDelayMs;
@@ -397,15 +407,64 @@ export class BulkQueue {
397407
}
398408
}
399409

400-
private async getResponseBodyPreview(res: Response) {
410+
private getApiErrorSummary(errorDetails: BulkErrorDetails) {
411+
const code = errorDetails.apiErrorCode;
412+
const message = errorDetails.apiErrorMessage;
413+
if (code && message) {
414+
return `${code}: ${message}`;
415+
}
416+
return message ?? code;
417+
}
418+
419+
private async getResponseErrorDetails(res: Response): Promise<BulkErrorDetails> {
401420
try {
402421
const body = await res.text();
403422
if (!body) {
404-
return undefined;
423+
return {};
405424
}
406-
return body.slice(0, 500);
425+
426+
let apiErrorCode: string | undefined;
427+
let apiErrorMessage: string | undefined;
428+
try {
429+
const parsed: unknown = JSON.parse(body);
430+
const parsedError = this.extractApiError(parsed);
431+
apiErrorCode = parsedError.code;
432+
apiErrorMessage = parsedError.message;
433+
} catch {
434+
// ignore JSON parse failures
435+
}
436+
437+
return {
438+
responseBody: body.slice(0, MAX_RESPONSE_BODY_PREVIEW_CHARS),
439+
apiErrorCode,
440+
apiErrorMessage,
441+
};
407442
} catch {
408-
return undefined;
443+
return {};
409444
}
410445
}
446+
447+
private extractApiError(value: unknown): { code?: string; message?: string } {
448+
if (!isObject(value)) {
449+
return {};
450+
}
451+
452+
const topLevelCode = typeof value.code === "string" ? value.code : undefined;
453+
const topLevelMessage =
454+
typeof value.message === "string" ? value.message : undefined;
455+
456+
const error = value.error;
457+
if (!isObject(error)) {
458+
return {
459+
code: topLevelCode,
460+
message: topLevelMessage,
461+
};
462+
}
463+
464+
return {
465+
code: typeof error.code === "string" ? error.code : topLevelCode,
466+
message:
467+
typeof error.message === "string" ? error.message : topLevelMessage,
468+
};
469+
}
411470
}

packages/browser-sdk/test/bulkQueue.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,48 @@ describe("BulkQueue", () => {
122122
expect(sendBulk).toHaveBeenCalledTimes(1);
123123
});
124124

125+
it("includes parsed API error details for non-retriable 4xx responses", async () => {
126+
const body = JSON.stringify({
127+
success: false,
128+
error: {
129+
message:
130+
'Invalid publishableKey "pub_prod_vxuMahSZOnhzvAfiOnZ9rj"',
131+
code: "INVALID_API_KEY",
132+
},
133+
});
134+
const sendBulk = vi
135+
.fn<(events: BulkEvent[]) => Promise<Response>>()
136+
.mockResolvedValue(
137+
new Response(body, {
138+
status: 401,
139+
headers: { "content-type": "application/json" },
140+
}),
141+
);
142+
const logger = {
143+
debug: vi.fn(),
144+
info: vi.fn(),
145+
warn: vi.fn(),
146+
error: vi.fn(),
147+
};
148+
const queue = new BulkQueue(sendBulk, {
149+
flushDelayMs: 10,
150+
logger,
151+
});
152+
153+
await queue.enqueue(trackEvent);
154+
await vi.advanceTimersByTimeAsync(10);
155+
156+
expect(logger.error).toHaveBeenCalledWith(
157+
expect.stringContaining("INVALID_API_KEY"),
158+
expect.objectContaining({
159+
status: 401,
160+
apiErrorCode: "INVALID_API_KEY",
161+
apiErrorMessage:
162+
'Invalid publishableKey "pub_prod_vxuMahSZOnhzvAfiOnZ9rj"',
163+
}),
164+
);
165+
});
166+
125167
it("does not drop newly queued events when an older batch completes", async () => {
126168
let resolveFirstSend: ((res: Response) => void) | undefined;
127169
const firstSend = new Promise<Response>((resolve) => {

0 commit comments

Comments
 (0)