diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 51c0b123d..9009dec72 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -501,7 +501,11 @@ export async function resolveOrgAllTarget( throw new ResolutionError( `Event ${eventId} in organization "${org}"`, "not found", - `sentry event view ${org}/ ${eventId}` + `sentry event view ${org}/ ${eventId}`, + [], + // Looking up an event ID that doesn't exist is an expected user miss, + // not a CLI bug — silence it from Sentry issues. + { expected: true } ); } return { @@ -700,7 +704,9 @@ export async function fetchEventWithContext( `Event '${eventId}'`, `not found in ${org}/${project}`, `sentry event view ${org}/ ${eventId}`, - suggestions + suggestions, + // Expected user miss: a specific event ID that doesn't exist. + { expected: true } ); } throw error; diff --git a/src/lib/error-reporting.ts b/src/lib/error-reporting.ts index 03cc87c28..6584a0636 100644 --- a/src/lib/error-reporting.ts +++ b/src/lib/error-reporting.ts @@ -3,9 +3,11 @@ * * 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`, opt-in `expected` `ResolutionError`, + * expected-state `AuthError`, and 401–499 `ApiError` are not sent to Sentry + * as issues. A `cli.error.silenced` metric preserves volume + user/org + * context. Resolution failures are silenced only when constructed with + * `{ expected: true }`; the class as a whole stays captured. * * 2. **Grouping tags** — enriches every error event with `cli_error.*` tags * that Sentry's server-side fingerprint rules use for stable grouping. @@ -42,7 +44,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" + | "auth_expected" + | "api_user_error" + | "user_input_error"; /** * Classify whether an error should be silenced. @@ -54,6 +60,13 @@ export function classifySilenced(error: unknown): SilenceReason | null { if (error instanceof OutputError) { return "output_error"; } + // Only silence resolution failures that explicitly opt in via `expected` + // (e.g. a looked-up event/issue ID that genuinely doesn't exist). The class + // as a whole stays captured — project/issue/replay/log/trace lookup failures + // remain observable for product/CLI telemetry. + if (error instanceof ResolutionError && error.expected) { + return "user_input_error"; + } if ( error instanceof AuthError && (error.reason === "not_authenticated" || error.reason === "expired") @@ -78,6 +91,12 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void { if (error instanceof AuthError) { attributes.auth_reason = error.reason; } + // Preserve the resource "kind" so silenced resolution misses keep some + // sub-grouping context (e.g. "Event not found" vs "Issue not found") in the + // metric instead of collapsing to a single error_class=ResolutionError tag. + if (error instanceof ResolutionError) { + attributes.resource_kind = extractResourceKind(error.resource); + } try { Sentry.metrics.distribution("cli.error.silenced", 1, { attributes }); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8427cf14c..b4408dd6f 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -474,18 +474,33 @@ export class ContextError extends CliError { * @param headline - Short phrase describing the failure (e.g., "not found", "is ambiguous", "could not be resolved") * @param hint - Primary usage example or suggestion (shown under "Try:") * @param suggestions - Additional help bullets shown under "Or:" (defaults to empty) + * @param options - Optional behavior overrides + * @param options.expected - When `true`, marks this failure as an expected, + * user-driven miss (e.g. a looked-up event/issue ID that genuinely doesn't + * exist) so it is silenced rather than captured as a Sentry issue. Defaults + * to `false`: by default resolution failures are still captured for + * product/CLI observability. Silencing is opt-in per call site, never + * class-wide — see `classifySilenced` in `error-reporting.ts`. */ export class ResolutionError extends CliError { readonly resource: string; readonly headline: string; readonly hint: string; readonly suggestions: string[]; + /** + * Whether this resolution failure is an expected user-driven miss that + * should be silenced (no Sentry issue, no crashed session). Opt-in per + * call site; defaults to `false` so failures remain observable. + */ + readonly expected: boolean; + // biome-ignore lint/nursery/useMaxParams: established 4-param shape; options is a defaulted extension constructor( resource: string, headline: string, hint: string, - suggestions: string[] = [] + suggestions: string[] = [], + options: { expected?: boolean } = {} ) { super( buildResolutionMessage(resource, headline, hint, suggestions), @@ -496,6 +511,7 @@ export class ResolutionError extends CliError { this.headline = headline; this.hint = hint; this.suggestions = suggestions; + this.expected = options.expected ?? false; } override format(): string { diff --git a/test/lib/error-reporting.test.ts b/test/lib/error-reporting.test.ts index 731d3c072..bc14e7a4f 100644 --- a/test/lib/error-reporting.test.ts +++ b/test/lib/error-reporting.test.ts @@ -2,7 +2,7 @@ * Unit tests for the central error reporting helper. * * Covers: - * - Silencing rules (OutputError / expected AuthError / 401–499 ApiError) + * - Silencing rules (OutputError / ResolutionError / expected AuthError / 401–499 ApiError) * - Grouping tag extraction (extractResourceKind) * - Tag enrichment in beforeSend (enrichEventWithGroupingTags) * - End-to-end behavior of reportCliError (metric emission + capture) @@ -244,9 +244,24 @@ describe("classifySilenced", () => { expect(classifySilenced(new ApiError("x", status))).toBeNull(); }); + test("silences ResolutionError only when expected:true", () => { + expect( + classifySilenced( + new ResolutionError( + "Event 'abc'", + "not found", + "sentry event view / abc", + [], + { expected: true } + ) + ) + ).toBe("user_input_error"); + }); + test.each([ ["ContextError", new ContextError("Organization", "sentry org view ")], [ + // Plain (non-expected) resolution failures stay captured for observability. "ResolutionError", new ResolutionError("Project 'x'", "not found", "sentry issue list"), ], @@ -447,7 +462,7 @@ describe("reportCliError integration", () => { expect(traceErr["cli_error.kind"]).not.toBe(eventErr["cli_error.kind"]); }); - test("captures ResolutionError", () => { + test("captures plain ResolutionError (not expected)", () => { const err = new ResolutionError( "Project 'x'", "not found", @@ -455,6 +470,31 @@ describe("reportCliError integration", () => { ); reportCliError(err); expect(captureSpy).toHaveBeenCalledWith(err); + expect(metricSpy).not.toHaveBeenCalled(); + }); + + test("silences expected ResolutionError and emits metric with resource_kind", () => { + reportCliError( + new ResolutionError( + "Event 'abc' in organization \"my-org\"", + "not found", + "sentry event view my-org/ abc", + [], + { expected: true } + ) + ); + expect(captureSpy).not.toHaveBeenCalled(); + expect(metricSpy).toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ + error_class: "ResolutionError", + reason: "user_input_error", + resource_kind: "Event", + }), + }) + ); }); test("captures SeerError (marketing dashboard)", () => {