diff --git a/packages/cli/src/lib/error-reporting.ts b/packages/cli/src/lib/error-reporting.ts index b8856107f..29101826c 100644 --- a/packages/cli/src/lib/error-reporting.ts +++ b/packages/cli/src/lib/error-reporting.ts @@ -45,6 +45,7 @@ import { ValidationError, WizardError, } from "./errors.js"; +import { isSaaS } from "./sentry-urls.js"; // --------------------------------------------------------------------------- // Silencing @@ -61,7 +62,8 @@ type SilenceReason = | "network_error" | "process_exit" | "user_validation" - | "user_input_error"; + | "user_input_error" + | "seer_unavailable_self_hosted"; /** * Classify whether an error should be silenced. @@ -123,6 +125,12 @@ export function classifySilenced(error: unknown): SilenceReason | null { if (error instanceof ApiError && error.status > 400 && error.status < 500) { return "api_user_error"; } + // On SaaS, SeerError stays captured: it feeds the marketing dashboard for + // Seer upsell/trial signal. Self-hosted instances cannot enable Seer or + // start a trial, so there it is pure feature-gate noise (CLI-1WP). + if (error instanceof SeerError && !isSaaS()) { + return "seer_unavailable_self_hosted"; + } // A child process launched by `sentry local run` or `sentry monitor run` // that exits with a non-zero code throws `CliError("Process exited with // code N")`. These are expected user-script failures, not CLI bugs — no @@ -153,6 +161,9 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void { if (error instanceof AuthError) { attributes.auth_reason = error.reason; } + if (error instanceof SeerError) { + attributes.seer_reason = error.reason; + } // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { diff --git a/packages/cli/test/lib/error-reporting.test.ts b/packages/cli/test/lib/error-reporting.test.ts index b212fd39b..df57db792 100644 --- a/packages/cli/test/lib/error-reporting.test.ts +++ b/packages/cli/test/lib/error-reporting.test.ts @@ -345,8 +345,49 @@ describe("classifySilenced", () => { ])("does NOT silence %s", (_label, err) => { expect(classifySilenced(err)).toBeNull(); }); + + test.each([ + "not_enabled", + "no_budget", + "ai_disabled", + ] as const)("silences SeerError(%s) on self-hosted (CLI-1WP)", (reason) => { + withSentryUrl("https://sentry.example.com", () => { + expect(classifySilenced(new SeerError(reason, "my-org"))).toBe( + "seer_unavailable_self_hosted" + ); + }); + }); + + test("does NOT silence SeerError when SENTRY_URL points at SaaS", () => { + withSentryUrl("https://sentry.io", () => { + expect(classifySilenced(new SeerError("not_enabled"))).toBeNull(); + }); + }); }); +/** + * Run `fn` with `SENTRY_URL` set to `url`, restoring the previous value after. + * `SENTRY_HOST` is cleared for the duration since it takes precedence. + */ +function withSentryUrl(url: string, fn: () => void): void { + const savedUrl = process.env.SENTRY_URL; + const savedHost = process.env.SENTRY_HOST; + process.env.SENTRY_URL = url; + delete process.env.SENTRY_HOST; + try { + fn(); + } finally { + if (savedUrl === undefined) { + delete process.env.SENTRY_URL; + } else { + process.env.SENTRY_URL = savedUrl; + } + if (savedHost !== undefined) { + process.env.SENTRY_HOST = savedHost; + } + } +} + // --------------------------------------------------------------------------- // enrichEventWithGroupingTags // --------------------------------------------------------------------------- @@ -571,6 +612,24 @@ describe("reportCliError integration", () => { expect(metricSpy).not.toHaveBeenCalled(); }); + test("silences SeerError on self-hosted and emits metric (CLI-1WP)", () => { + withSentryUrl("https://sentry.example.com", () => { + reportCliError(new SeerError("not_enabled", "my-org")); + }); + expect(captureSpy).not.toHaveBeenCalled(); + expect(metricSpy).toHaveBeenCalledWith( + "cli.error.silenced", + 1, + expect.objectContaining({ + attributes: expect.objectContaining({ + error_class: "SeerError", + reason: "seer_unavailable_self_hosted", + seer_reason: "not_enabled", + }), + }) + ); + }); + test("silences AuthError(invalid) and emits metric", () => { reportCliError(new AuthError("invalid")); expect(captureSpy).not.toHaveBeenCalled();