Skip to content

Commit 19e88e8

Browse files
authored
fix(telemetry): reduce Sentry issue fragmentation with stable fingerprinting (#769)
## Summary - Add central `reportCliError()` helper that sets `cli_error.*` tags on Sentry events for server-side fingerprint rules - Emit `cli.error.silenced` metric + structured log for silenced errors (401–499 ApiError, expected AuthError, OutputError) - Configure 9 server-side fingerprint rules in Sentry project settings to collapse fragmented issues ## Motivation Live Sentry data showed severe grouping fragmentation: the same logical error was split across many issues because messages embed user data and stack traces differ by call site. | Logical error | Distinct Sentry issues | |---|---:| | `ContextError: Could not auto-detect organization and project` | **13** | | `ContextError: Event ID is required` | **4** | | `ResolutionError: Project '<slug>' not found` | **15+** | | `ApiError: Short ID '<X>' not found in org '<Y>'` | **5** | | `ValidationError: Invalid trace ID "<X>"` | **5** | ## Approach Instead of a large SDK-side fingerprint normalization engine, we use: 1. **Minimal SDK tags** (`cli_error.class`, `cli_error.kind`, `cli_error.api_status`) — set via `reportCliError()` in `withScope` and `enrichEventWithGroupingTags()` in `beforeSend` 2. **Server-side fingerprint rules** in Sentry (Settings → Issue Grouping → Fingerprint Rules) — adjustable without deploys: ``` tags.cli_error.class:"ContextError" -> cli-context-error, {{ tags.cli_error.kind }} tags.cli_error.class:"ResolutionError" -> cli-resolution-error, {{ tags.cli_error.kind }} tags.cli_error.class:"ApiError" -> cli-api-error, {{ tags.cli_error.api_status }}, {{ tags.command }} ... ``` ## Design decisions - **User-input errors stay captured** — UX visibility for ContextError, ResolutionError, ValidationError - **SeerError stays captured** — feeds the marketing demand dashboard - **401–499 ApiError silencing** preserved — now with metric + log for observability - **Server-side rules over SDK fingerprints** — adjustable without deploys, visible to the whole team in project settings
1 parent a2267ab commit 19e88e8

7 files changed

Lines changed: 789 additions & 100 deletions

File tree

AGENTS.md

Lines changed: 54 additions & 71 deletions
Large diffs are not rendered by default.

src/app.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
2-
import * as Sentry from "@sentry/node-core/light";
31
import {
42
type ApplicationText,
53
buildApplication,
@@ -44,8 +42,8 @@ import {
4442
getSynonymSuggestionFromArgv,
4543
} from "./lib/command-suggestions.js";
4644
import { CLI_VERSION } from "./lib/constants.js";
45+
import { reportCliError } from "./lib/error-reporting.js";
4746
import {
48-
ApiError,
4947
AuthError,
5048
CliError,
5149
getExitCode,
@@ -321,17 +319,12 @@ const customText: ApplicationText = {
321319
return synonymResult;
322320
}
323321

324-
// Report command errors to Sentry. Stricli catches exceptions and doesn't
325-
// re-throw, so we must capture here to get visibility into command failures.
326-
// 400 Bad Request = CLI bug (we constructed a malformed request).
327-
if (exc instanceof ApiError) {
328-
Sentry.setContext("api_error", {
329-
status: exc.status,
330-
endpoint: exc.endpoint,
331-
detail: exc.detail,
332-
});
333-
}
334-
Sentry.captureException(exc);
322+
// Report command errors to Sentry with stable fingerprinting. Stricli
323+
// catches exceptions and doesn't re-throw, so we must capture here to
324+
// get visibility into command failures. Silencing rules (OutputError,
325+
// expected AuthError, 401–499 ApiError) and fingerprint normalization
326+
// are enforced inside reportCliError. 400 Bad Request = CLI bug.
327+
reportCliError(exc);
335328

336329
if (exc instanceof CliError) {
337330
// WizardError with rendered=true: clack already displayed the error.

src/lib/error-reporting.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/**
2+
* Central error reporting for CLI command failures.
3+
*
4+
* Provides two things:
5+
*
6+
* 1. **Silencing rules** — `OutputError`, expected-state `AuthError`, and
7+
* 401–499 `ApiError` are not sent to Sentry as issues. A
8+
* `cli.error.silenced` metric preserves volume + user/org context.
9+
*
10+
* 2. **Grouping tags** — enriches every error event with `cli_error.*` tags
11+
* that Sentry's server-side fingerprint rules use for stable grouping.
12+
* The rules live in Settings → Issue Grouping → Fingerprint Rules and
13+
* can be adjusted without a deploy.
14+
*
15+
* Fingerprint normalization is handled server-side, NOT in code. See the
16+
* project's fingerprint rules for the active grouping policy.
17+
*/
18+
19+
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
20+
import * as Sentry from "@sentry/node-core/light";
21+
import {
22+
ApiError,
23+
AuthError,
24+
ContextError,
25+
DeviceFlowError,
26+
OutputError,
27+
ResolutionError,
28+
SeerError,
29+
TimeoutError,
30+
UpgradeError,
31+
ValidationError,
32+
} from "./errors.js";
33+
34+
// ---------------------------------------------------------------------------
35+
// Silencing
36+
// ---------------------------------------------------------------------------
37+
38+
/**
39+
* Reasons an error may be silenced (not sent to Sentry as an issue).
40+
* Exposed as the `reason` attribute on the `cli.error.silenced` metric.
41+
*/
42+
type SilenceReason = "output_error" | "auth_expected" | "api_user_error";
43+
44+
/**
45+
* Classify whether an error should be silenced.
46+
* Returns the reason string when silenced, `null` when it should be captured.
47+
*
48+
* @internal Exported for `telemetry.ts` (session-crash decision) and testing.
49+
*/
50+
export function classifySilenced(error: unknown): SilenceReason | null {
51+
if (error instanceof OutputError) {
52+
return "output_error";
53+
}
54+
if (
55+
error instanceof AuthError &&
56+
(error.reason === "not_authenticated" || error.reason === "expired")
57+
) {
58+
return "auth_expected";
59+
}
60+
if (error instanceof ApiError && error.status > 400 && error.status < 500) {
61+
return "api_user_error";
62+
}
63+
return null;
64+
}
65+
66+
/** Emit a metric for silenced errors so volume remains visible. */
67+
function recordSilencedError(error: unknown, reason: SilenceReason): void {
68+
const attributes: Record<string, string | number> = {
69+
error_class: error instanceof Error ? error.name : typeof error,
70+
reason,
71+
};
72+
if (error instanceof ApiError) {
73+
attributes.api_status = error.status;
74+
}
75+
if (error instanceof AuthError) {
76+
attributes.auth_reason = error.reason;
77+
}
78+
79+
try {
80+
Sentry.metrics.distribution("cli.error.silenced", 1, { attributes });
81+
} catch {
82+
// Metric emission must never block error handling.
83+
}
84+
85+
// Structured log for user API errors — `detail` is often the most actionable
86+
// field and is searchable in Sentry Logs.
87+
if (reason === "api_user_error" && error instanceof ApiError) {
88+
try {
89+
Sentry.logger.info("cli.api_error_silenced", {
90+
status: error.status,
91+
endpoint: error.endpoint,
92+
detail: error.detail,
93+
});
94+
} catch {
95+
// Logger may not be initialized — ignore.
96+
}
97+
}
98+
}
99+
100+
// ---------------------------------------------------------------------------
101+
// Grouping tags
102+
// ---------------------------------------------------------------------------
103+
104+
/**
105+
* Strip quoted substrings and numeric/hex IDs from a resource string to
106+
* produce a stable "kind" for grouping.
107+
*
108+
* `"Project 'my-app' not found"` → `"Project not found"`
109+
* `"Issue 7420431306 not found."` → `"Issue not found."`
110+
*/
111+
export function extractResourceKind(resource: string): string {
112+
return resource
113+
.replace(/'[^']*'/g, "")
114+
.replace(/"[^"]*"/g, "")
115+
.replace(/\b[0-9a-f]{16,32}\b/gi, "")
116+
.replace(/\b\d{6,}\b/g, "")
117+
.replace(/\s+/g, " ")
118+
.trim();
119+
}
120+
121+
/**
122+
* Set `cli_error.*` tags on a Sentry scope for an error that will be
123+
* captured. These tags are matched by server-side fingerprint rules to
124+
* achieve stable grouping without SDK-side fingerprint logic.
125+
*
126+
* Tags set:
127+
* - `cli_error.class` — error class name (e.g. `"ContextError"`)
128+
* - `cli_error.kind` — stable grouping key derived from structured fields
129+
* - `cli_error.api_status` — HTTP status (ApiError only)
130+
*/
131+
function setGroupingTags(scope: Sentry.Scope, error: unknown): void {
132+
if (!(error instanceof Error)) {
133+
return;
134+
}
135+
136+
scope.setTag("cli_error.class", error.name);
137+
138+
if (error instanceof ContextError) {
139+
scope.setTag("cli_error.kind", error.resource);
140+
} else if (error instanceof ResolutionError) {
141+
scope.setTag(
142+
"cli_error.kind",
143+
extractResourceKind(error.resource) +
144+
" " +
145+
extractResourceKind(error.headline)
146+
);
147+
} else if (error instanceof ValidationError) {
148+
scope.setTag("cli_error.kind", error.field ?? "");
149+
} else if (error instanceof ApiError) {
150+
scope.setTag("cli_error.api_status", String(error.status));
151+
scope.setTag("cli_error.kind", String(error.status));
152+
} else if (error instanceof SeerError) {
153+
scope.setTag("cli_error.kind", error.reason);
154+
} else if (error instanceof AuthError) {
155+
scope.setTag("cli_error.kind", error.reason);
156+
} else if (error instanceof UpgradeError) {
157+
scope.setTag("cli_error.kind", error.reason);
158+
} else if (error instanceof DeviceFlowError) {
159+
scope.setTag("cli_error.kind", error.code);
160+
} else if (error instanceof TimeoutError) {
161+
scope.setTag("cli_error.kind", "timeout");
162+
}
163+
}
164+
165+
// ---------------------------------------------------------------------------
166+
// Structured context
167+
// ---------------------------------------------------------------------------
168+
169+
/** Attach a `cli_error` context with full structured details for Discover. */
170+
function setCliErrorContext(scope: Sentry.Scope, error: unknown): void {
171+
if (error instanceof ApiError) {
172+
scope.setContext("api_error", {
173+
status: error.status,
174+
endpoint: error.endpoint,
175+
detail: error.detail,
176+
});
177+
} else if (error instanceof ContextError) {
178+
scope.setContext("cli_error", {
179+
resource: error.resource,
180+
command: error.command,
181+
});
182+
} else if (error instanceof ResolutionError) {
183+
scope.setContext("cli_error", {
184+
resource: error.resource,
185+
headline: error.headline,
186+
hint: error.hint,
187+
});
188+
} else if (error instanceof SeerError) {
189+
scope.setContext("cli_error", {
190+
reason: error.reason,
191+
org_slug: error.orgSlug,
192+
});
193+
}
194+
}
195+
196+
// ---------------------------------------------------------------------------
197+
// Public API
198+
// ---------------------------------------------------------------------------
199+
200+
/**
201+
* Report a command-level error to Sentry.
202+
*
203+
* - Silenced errors emit a metric and return without calling `captureException`.
204+
* - Captured errors get grouping tags + structured context on a fresh scope.
205+
*/
206+
export function reportCliError(error: unknown): void {
207+
const silenced = classifySilenced(error);
208+
if (silenced) {
209+
recordSilencedError(error, silenced);
210+
return;
211+
}
212+
213+
Sentry.withScope((scope) => {
214+
setGroupingTags(scope, error);
215+
setCliErrorContext(scope, error);
216+
Sentry.captureException(error);
217+
});
218+
}
219+
220+
/**
221+
* Enrich an error event with `cli_error.*` tags for server-side fingerprinting.
222+
*
223+
* Called from `beforeSend` to catch events that bypass {@link reportCliError}
224+
* (uncaught exceptions, unhandled rejections, best-effort background captures).
225+
* Only sets tags when `cli_error.class` isn't already present (events from
226+
* `reportCliError` already have them).
227+
*/
228+
export function enrichEventWithGroupingTags(
229+
event: Sentry.ErrorEvent
230+
): Sentry.ErrorEvent {
231+
// Skip if reportCliError already set the tags via withScope.
232+
if (event.tags?.["cli_error.class"]) {
233+
return event;
234+
}
235+
236+
// Use the last (outermost/thrown) exception in the chain, not the first
237+
// (innermost/root cause). Per the Sentry protocol, values[0] is the root
238+
// cause and values[n-1] is the actually-thrown exception.
239+
const values = event.exception?.values;
240+
const exc = values?.[values.length - 1];
241+
if (!exc?.type) {
242+
return event;
243+
}
244+
245+
event.tags = event.tags ?? {};
246+
event.tags["cli_error.class"] = exc.type;
247+
248+
return event;
249+
}

src/lib/telemetry.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ import {
2222
import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js";
2323
import { detectAgent, detectAgentFromProcessTree } from "./detect-agent.js";
2424
import { getEnv } from "./env.js";
25-
import { ApiError, AuthError, OutputError } from "./errors.js";
25+
import {
26+
classifySilenced,
27+
enrichEventWithGroupingTags,
28+
reportCliError,
29+
} from "./error-reporting.js";
30+
import { ApiError } from "./errors.js";
2631
import { attachSentryReporter, logger } from "./logger.js";
2732
import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js";
2833
import { getRealUsername } from "./utils.js";
@@ -215,19 +220,15 @@ export async function withTelemetry<T>(
215220
}
216221
);
217222
} catch (e) {
218-
const isExpectedAuthState =
219-
e instanceof AuthError &&
220-
(e.reason === "not_authenticated" || e.reason === "expired");
221-
// User API errors (401–499) are user errors (wrong ID, no access), not
222-
// CLI bugs. They're recorded as span attributes above for volume-spike
223-
// detection. 400 Bad Request is NOT filtered — it indicates a CLI bug.
224-
// OutputError is an intentional non-zero exit (e.g., `sentry api` got a
225-
// 4xx/5xx response) — not a CLI bug. Error details are recorded as span
226-
// attributes by the command itself.
227-
if (
228-
!(isExpectedAuthState || isUserApiError(e) || e instanceof OutputError)
229-
) {
230-
Sentry.captureException(e);
223+
// Route through reportCliError so silencing (OutputError, expected-auth
224+
// AuthError, 401–499 ApiError) and fingerprint normalization are applied
225+
// consistently. Silenced errors emit a `cli.error.silenced` metric +
226+
// optional structured log instead of creating a Sentry issue.
227+
reportCliError(e);
228+
// Only mark session crashed for errors that weren't silenced.
229+
// Silenced errors (OutputError, expected AuthError, user 4xx ApiError)
230+
// are expected states — marking them crashed would skew release-health.
231+
if (!classifySilenced(e)) {
231232
markSessionCrashed();
232233
}
233234
throw e;
@@ -581,7 +582,10 @@ export function initSentry(
581582
// silently skipping resolution for relative paths.
582583
normalizeFramePaths(event);
583584

584-
return event;
585+
// Enrich events with cli_error.* tags for server-side fingerprint rules.
586+
// reportCliError already sets these for command-level errors; this
587+
// catches uncaught exceptions and best-effort background captures.
588+
return enrichEventWithGroupingTags(event);
585589
},
586590
});
587591

0 commit comments

Comments
 (0)