Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions src/lib/error-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
*
* Provides two things:
*
* 1. **Silencing rules** — `OutputError`, expected-state `AuthError`, and
* 401–499 `ApiError` are not sent to Sentry as issues. A
* `cli.error.silenced` metric preserves volume + user/org context.
* 1. **Silencing rules** — `OutputError`, `ContextError` (a required value the
* user omitted), expected-state `AuthError`, and 401–499 `ApiError` are not
* sent to Sentry as issues. A `cli.error.silenced` metric preserves volume +
* user/org context.
*
* 2. **Grouping tags** — enriches every error event with `cli_error.*` tags
* that Sentry's server-side fingerprint rules use for stable grouping.
Expand Down Expand Up @@ -42,7 +43,11 @@ import {
* Reasons an error may be silenced (not sent to Sentry as an issue).
* Exposed as the `reason` attribute on the `cli.error.silenced` metric.
*/
type SilenceReason = "output_error" | "auth_expected" | "api_user_error";
type SilenceReason =
| "output_error"
| "context_missing"
| "auth_expected"
| "api_user_error";

/**
* Classify whether an error should be silenced.
Expand All @@ -54,6 +59,16 @@ export function classifySilenced(error: unknown): SilenceReason | null {
if (error instanceof OutputError) {
return "output_error";
}
// A ContextError always means the user omitted a required value (no
// org/project could be resolved, a required ID was not provided, etc.). It is
// never a CLI bug — unlike ResolutionError, where a *provided* value that
// can't be matched may signal a product/access issue worth observing. There
// is nothing per-instance to investigate, so silence the whole class; the
// `cli.error.silenced` metric (keyed by `error_class` + `resource`) preserves
// the volume and which value was missing. (CLI-3B: ~2000 users.)
if (error instanceof ContextError) {
return "context_missing";
}
if (
error instanceof AuthError &&
(error.reason === "not_authenticated" || error.reason === "expired")
Expand All @@ -78,6 +93,12 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void {
if (error instanceof AuthError) {
attributes.auth_reason = error.reason;
}
// Preserve which required value was missing so the silenced-volume metric
// keeps sub-grouping context (e.g. "Organization and project" vs "Event ID").
// ContextError resources are a small fixed set, so cardinality stays low.
if (error instanceof ContextError) {
attributes.resource = error.resource;
}

try {
Sentry.metrics.distribution("cli.error.silenced", 1, { attributes });
Expand Down
13 changes: 7 additions & 6 deletions src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,15 @@ export async function withTelemetry<T>(
}
);
} catch (e) {
// Route through reportCliError so silencing (OutputError, expected-auth
// AuthError, 401–499 ApiError) and fingerprint normalization are applied
// consistently. Silenced errors emit a `cli.error.silenced` metric +
// optional structured log instead of creating a Sentry issue.
// Route through reportCliError so silencing (OutputError, ContextError,
// expected-auth AuthError, 401–499 ApiError) and fingerprint normalization
// are applied consistently. Silenced errors emit a `cli.error.silenced`
// metric + optional structured log instead of creating a Sentry issue.
reportCliError(e);
// Only mark session crashed for errors that weren't silenced.
// Silenced errors (OutputError, expected AuthError, user 4xx ApiError)
// are expected states — marking them crashed would skew release-health.
// Silenced errors (OutputError, ContextError, expected AuthError, user 4xx
// ApiError) are expected states — marking them crashed would skew
// release-health.
if (!classifySilenced(e)) {
markSessionCrashed();
}
Expand Down
30 changes: 23 additions & 7 deletions test/lib/error-reporting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,13 @@ describe("classifySilenced", () => {
});

test.each([
["ContextError", new ContextError("Organization", "sentry org view <x>")],
["auto-detect failure", new ContextError("Organization and project", "x")],
["missing ID", new ContextError("Event ID", "sentry event view <id>", [])],
])("silences ContextError (%s)", (_label, err) => {
expect(classifySilenced(err)).toBe("context_missing");
});

test.each([
[
"ResolutionError",
new ResolutionError("Project 'x'", "not found", "sentry issue list"),
Expand Down Expand Up @@ -402,12 +408,22 @@ describe("reportCliError integration", () => {
return { tags, contexts };
}

test("captures ContextError with scope (tags applied)", () => {
const err = new ContextError("Organization", "sentry org view <slug>");
reportCliError(err);
expect(captureSpy).toHaveBeenCalledWith(err);
expect(withScopeSpy).toHaveBeenCalled();
expect(metricSpy).not.toHaveBeenCalled();
test("silences ContextError and emits metric with resource", () => {
reportCliError(
new ContextError("Organization and project", "sentry org view <slug>")
);
expect(captureSpy).not.toHaveBeenCalled();
expect(metricSpy).toHaveBeenCalledWith(
"cli.error.silenced",
1,
expect.objectContaining({
attributes: expect.objectContaining({
error_class: "ContextError",
reason: "context_missing",
resource: "Organization and project",
}),
})
);
});

test("ValidationError with field uses field as kind", () => {
Expand Down
9 changes: 5 additions & 4 deletions test/lib/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ describe("withTelemetry", () => {
captureSpy.mockRestore();
});

test("captures ContextError with fingerprint (no silencing)", async () => {
test("silences ContextError (missing user input, not a crash)", async () => {
const captureSpy = vi.spyOn(Sentry, "captureException");
const metricSpy = vi.spyOn(Sentry.metrics, "distribution");
const { ContextError } = await import("../../src/lib/errors.js");
Expand All @@ -302,12 +302,13 @@ describe("withTelemetry", () => {
throw error;
})
).rejects.toThrow(error);
expect(captureSpy).toHaveBeenCalledWith(error);
// No silencing metric for captured errors
// ContextError is an expected "missing input" error — not reported as a
// Sentry issue; volume is preserved via the cli.error.silenced metric.
expect(captureSpy).not.toHaveBeenCalled();
const silencedCall = metricSpy.mock.calls.find(
(c) => c[0] === "cli.error.silenced"
);
expect(silencedCall).toBeUndefined();
expect(silencedCall).toBeDefined();
captureSpy.mockRestore();
metricSpy.mockRestore();
});
Expand Down
Loading