Skip to content

Commit b6444ca

Browse files
committed
fix(telemetry): rename isClientApiError to isUserApiError and exclude 400
400 Bad Request indicates a CLI bug (malformed API request the CLI should never send), not a user error. The previous isClientApiError treated all 4xx uniformly, which would silently swallow 400s if they escaped Stricli. Rename to isUserApiError for clarity and change the boundary from >= 400 to > 400, ensuring 400 Bad Request is captured as an exception while 401-499 continue to be recorded as span attributes only.
1 parent 1163dac commit b6444ca

2 files changed

Lines changed: 37 additions & 28 deletions

File tree

src/lib/telemetry.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,11 @@ export async function withTelemetry<T>(
200200
try {
201201
return await callback(span);
202202
} catch (e) {
203-
// Record 4xx API errors as span attributes instead of exceptions.
204-
// These are user errors (wrong ID, no access) not CLI bugs, but
205-
// recording on the span lets us detect volume spikes in Discover.
206-
if (isClientApiError(e)) {
203+
// Record user API errors (401–499) as span attributes instead of
204+
// exceptions. These are user errors (wrong ID, no access), not CLI
205+
// bugs. Recording on the span lets us detect volume spikes in Discover.
206+
// 400 Bad Request is NOT filtered here — it's a CLI bug.
207+
if (isUserApiError(e)) {
207208
recordApiErrorOnSpan(span, e as ApiError);
208209
}
209210
throw e;
@@ -216,13 +217,14 @@ export async function withTelemetry<T>(
216217
const isExpectedAuthState =
217218
e instanceof AuthError &&
218219
(e.reason === "not_authenticated" || e.reason === "expired");
219-
// 4xx API errors are user errors (wrong ID, no access), not CLI bugs.
220-
// They're recorded as span attributes above for volume-spike detection.
220+
// User API errors (401–499) are user errors (wrong ID, no access), not
221+
// CLI bugs. They're recorded as span attributes above for volume-spike
222+
// detection. 400 Bad Request is NOT filtered — it indicates a CLI bug.
221223
// OutputError is an intentional non-zero exit (e.g., `sentry api` got a
222224
// 4xx/5xx response) — not a CLI bug. Error details are recorded as span
223225
// attributes by the command itself.
224226
if (
225-
!(isExpectedAuthState || isClientApiError(e) || e instanceof OutputError)
227+
!(isExpectedAuthState || isUserApiError(e) || e instanceof OutputError)
226228
) {
227229
Sentry.captureException(e);
228230
markSessionCrashed();
@@ -304,16 +306,19 @@ export function isEpipeError(event: Sentry.ErrorEvent): boolean {
304306
}
305307

306308
/**
307-
* Check if an error is a client-side (4xx) API error.
309+
* Check if an error is a user-caused (401–499) API error.
308310
*
309-
* 4xx errors are user errors — wrong issue IDs, no access, invalid input —
310-
* not CLI bugs. These should be recorded as span attributes for volume-spike
311-
* detection in Discover, but should NOT be captured as Sentry exceptions.
311+
* 401–499 errors are user errors — wrong issue IDs, no access, rate limited —
312+
* not CLI bugs. 400 Bad Request is **excluded** because it indicates the CLI
313+
* constructed a malformed API request, which is a code defect.
314+
*
315+
* These should be recorded as span attributes for volume-spike detection in
316+
* Discover, but should NOT be captured as Sentry exceptions.
312317
*
313318
* @internal Exported for testing
314319
*/
315-
export function isClientApiError(error: unknown): boolean {
316-
return error instanceof ApiError && error.status >= 400 && error.status < 500;
320+
export function isUserApiError(error: unknown): boolean {
321+
return error instanceof ApiError && error.status > 400 && error.status < 500;
317322
}
318323

319324
/**

test/lib/telemetry.test.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
createTracedDatabase,
2323
getSentryTracePropagationTargets,
2424
initSentry,
25-
isClientApiError,
25+
isUserApiError,
2626
recordApiErrorOnSpan,
2727
resetReadonlyWarning,
2828
setArgsContext,
@@ -235,51 +235,55 @@ describe("withTelemetry", () => {
235235
});
236236
});
237237

238-
describe("isClientApiError", () => {
239-
test("returns true for 400 Bad Request", () => {
240-
expect(isClientApiError(new ApiError("Bad request", 400))).toBe(true);
238+
describe("isUserApiError", () => {
239+
test("returns false for 400 Bad Request (CLI bug, not user error)", () => {
240+
expect(isUserApiError(new ApiError("Bad request", 400))).toBe(false);
241+
});
242+
243+
test("returns true for 401 Unauthorized", () => {
244+
expect(isUserApiError(new ApiError("Unauthorized", 401))).toBe(true);
241245
});
242246

243247
test("returns true for 403 Forbidden", () => {
244-
expect(isClientApiError(new ApiError("Forbidden", 403, "No access"))).toBe(
248+
expect(isUserApiError(new ApiError("Forbidden", 403, "No access"))).toBe(
245249
true
246250
);
247251
});
248252

249253
test("returns true for 404 Not Found", () => {
250254
expect(
251-
isClientApiError(new ApiError("Not found", 404, "Issue not found"))
255+
isUserApiError(new ApiError("Not found", 404, "Issue not found"))
252256
).toBe(true);
253257
});
254258

255259
test("returns true for 429 Too Many Requests", () => {
256-
expect(isClientApiError(new ApiError("Rate limited", 429))).toBe(true);
260+
expect(isUserApiError(new ApiError("Rate limited", 429))).toBe(true);
257261
});
258262

259263
test("returns false for 500 Internal Server Error", () => {
260-
expect(isClientApiError(new ApiError("Server error", 500))).toBe(false);
264+
expect(isUserApiError(new ApiError("Server error", 500))).toBe(false);
261265
});
262266

263267
test("returns false for 502 Bad Gateway", () => {
264-
expect(isClientApiError(new ApiError("Bad gateway", 502))).toBe(false);
268+
expect(isUserApiError(new ApiError("Bad gateway", 502))).toBe(false);
265269
});
266270

267271
test("returns false for non-ApiError", () => {
268-
expect(isClientApiError(new Error("generic error"))).toBe(false);
272+
expect(isUserApiError(new Error("generic error"))).toBe(false);
269273
});
270274

271275
test("returns false for AuthError", () => {
272-
expect(isClientApiError(new AuthError("not_authenticated"))).toBe(false);
276+
expect(isUserApiError(new AuthError("not_authenticated"))).toBe(false);
273277
});
274278

275279
test("returns false for null/undefined", () => {
276-
expect(isClientApiError(null)).toBe(false);
277-
expect(isClientApiError(undefined)).toBe(false);
280+
expect(isUserApiError(null)).toBe(false);
281+
expect(isUserApiError(undefined)).toBe(false);
278282
});
279283

280284
test("returns false for non-Error objects", () => {
281-
expect(isClientApiError({ status: 404 })).toBe(false);
282-
expect(isClientApiError("404")).toBe(false);
285+
expect(isUserApiError({ status: 404 })).toBe(false);
286+
expect(isUserApiError("404")).toBe(false);
283287
});
284288
});
285289

0 commit comments

Comments
 (0)