Skip to content
Open
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
10 changes: 8 additions & 2 deletions src/commands/event/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,11 @@ export async function resolveOrgAllTarget(
throw new ResolutionError(
`Event ${eventId} in organization "${org}"`,
"not found",
`sentry event view ${org}/<project> ${eventId}`
`sentry event view ${org}/<project> ${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 {
Expand Down Expand Up @@ -700,7 +704,9 @@ export async function fetchEventWithContext(
`Event '${eventId}'`,
`not found in ${org}/${project}`,
`sentry event view ${org}/<project> ${eventId}`,
suggestions
suggestions,
// Expected user miss: a specific event ID that doesn't exist.
{ expected: true }
);
}
throw error;
Expand Down
27 changes: 23 additions & 4 deletions src/lib/error-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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")
Expand All @@ -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 });
Expand Down
18 changes: 17 additions & 1 deletion src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 {
Expand Down
44 changes: 42 additions & 2 deletions test/lib/error-reporting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <org>/<project> abc",
[],
{ expected: true }
)
)
).toBe("user_input_error");
});

test.each([
["ContextError", new ContextError("Organization", "sentry org view <x>")],
[
// Plain (non-expected) resolution failures stay captured for observability.
"ResolutionError",
new ResolutionError("Project 'x'", "not found", "sentry issue list"),
],
Expand Down Expand Up @@ -447,14 +462,39 @@ 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",
"sentry issue list <org>/x"
);
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/<project> 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)", () => {
Expand Down
Loading