From e18a79dc9e9fd4946bb8b02bd88cb29ac90ccca1 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 10:56:03 +0000 Subject: [PATCH 01/10] feat(status): add `sentry status` command group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `sentry status` command backed by the public Sentry status page (https://status.sentry.io) Statuspage API. It reports the overall service indicator, any active incidents, and impacted components, and works even when the Sentry API itself is degraded — the request is bounded by an explicit 10s timeout so a status check never hangs. - `sentry status` (defaults to `show`) prints human-readable status - `--json` emits structured data for scripting - `--url` points at a self-hosted or regional Statuspage instance Addresses item 2 of the issue. Request timeouts (item 1) already exist in the shared API client (sentry-client.ts). Fixes #1493 --- .../cli-docs/src/content/docs/contributing.md | 1 + .../cli-docs/src/fragments/commands/status.md | 29 ++++ .../sentry-cli/skills/sentry-cli/SKILL.md | 8 + .../skills/sentry-cli/references/status.md | 34 +++++ packages/cli/src/app.ts | 2 + packages/cli/src/commands/status/index.ts | 23 +++ packages/cli/src/commands/status/show.ts | 57 +++++++ packages/cli/src/lib/api/status-page.ts | 129 ++++++++++++++++ packages/cli/src/lib/formatters/human.ts | 85 +++++++++++ .../cli/test/commands/status/show.test.ts | 144 ++++++++++++++++++ 10 files changed, 512 insertions(+) create mode 100644 apps/cli-docs/src/fragments/commands/status.md create mode 100644 packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md create mode 100644 packages/cli/src/commands/status/index.ts create mode 100644 packages/cli/src/commands/status/show.ts create mode 100644 packages/cli/src/lib/api/status-page.ts create mode 100644 packages/cli/test/commands/status/show.test.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index e93558b8a..6cd16ca68 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -79,6 +79,7 @@ cli/ │ │ ├── snapshots/ # diff, download, upload │ │ ├── sourcemap/ # inject, resolve, upload │ │ ├── span/ # list, view +│ │ ├── status/ # show │ │ ├── team/ # list │ │ ├── trace/ # list, logs, view │ │ ├── trial/ # list, start diff --git a/apps/cli-docs/src/fragments/commands/status.md b/apps/cli-docs/src/fragments/commands/status.md new file mode 100644 index 000000000..18e78641a --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/status.md @@ -0,0 +1,29 @@ + + +## Examples + +```bash +# Show the current status of Sentry's services +sentry status +``` + +``` +✓ All Systems Operational + +### Components + +● Dashboard — Operational +● US Error Ingestion — Operational + +See https://status.sentry.io for full details. +``` + +```bash +# Get machine-readable status (useful in scripts) +sentry status --json +``` + +```bash +# Check a self-hosted or regional status page +sentry status --url https://status.example.com +``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index adf3d8893..71b7ac230 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -617,6 +617,14 @@ List and view spans in projects or traces → Full flags and examples: `references/span.md` +### Status + +Check Sentry service status + +- `sentry status show` — Show Sentry service status + +→ Full flags and examples: `references/status.md` + ### Trace View distributed traces diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md new file mode 100644 index 000000000..f7ad4d438 --- /dev/null +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md @@ -0,0 +1,34 @@ +--- +name: sentry-cli-status +version: 0.44.0-dev.0 +description: Check Sentry service status +requires: + bins: ["sentry"] + auth: true +--- + +# Status Commands + +Check Sentry service status + +### `sentry status show` + +Show Sentry service status + +**Flags:** +- `--url - Status page base URL to query - (default: "https://status.sentry.io")` + +**Examples:** + +```bash +# Show the current status of Sentry's services +sentry status + +# Get machine-readable status (useful in scripts) +sentry status --json + +# Check a self-hosted or regional status page +sentry status --url https://status.example.com +``` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 88332cdc6..af498349d 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -55,6 +55,7 @@ import { snapshotsRoute } from "./commands/snapshots/index.js"; import { sourcemapRoute } from "./commands/sourcemap/index.js"; import { spanRoute } from "./commands/span/index.js"; import { listCommand as spanListCommand } from "./commands/span/list.js"; +import { statusRoute } from "./commands/status/index.js"; import { teamRoute } from "./commands/team/index.js"; import { listCommand as teamListCommand } from "./commands/team/list.js"; import { traceRoute } from "./commands/trace/index.js"; @@ -140,6 +141,7 @@ export const routes = buildRouteMap({ sourcemap: sourcemapRoute, sourcemaps: sourcemapRoute, span: spanRoute, + status: statusRoute, trace: traceRoute, trial: trialRoute, init: initCommand, diff --git a/packages/cli/src/commands/status/index.ts b/packages/cli/src/commands/status/index.ts new file mode 100644 index 000000000..cea082c6d --- /dev/null +++ b/packages/cli/src/commands/status/index.ts @@ -0,0 +1,23 @@ +/** + * sentry status + * + * Check the status of Sentry's services. + */ + +import { buildRouteMap } from "../../lib/route-map.js"; +import { showCommand } from "./show.js"; + +export const statusRoute = buildRouteMap({ + routes: { + show: showCommand, + }, + defaultCommand: "show", + docs: { + brief: "Check Sentry service status", + fullDescription: + "Report the current status of Sentry's services using the public " + + "status page (https://status.sentry.io) as the backend.\n\n" + + "Running `sentry status` with no subcommand shows the current status.", + hideRoute: {}, + }, +}); diff --git a/packages/cli/src/commands/status/show.ts b/packages/cli/src/commands/status/show.ts new file mode 100644 index 000000000..e9d82aaf0 --- /dev/null +++ b/packages/cli/src/commands/status/show.ts @@ -0,0 +1,57 @@ +/** + * sentry status show + * + * Report the current status of Sentry's services by querying the public + * Statuspage backend at https://status.sentry.io. Useful when the Sentry API + * is degraded and you want to know whether the problem is on Sentry's side. + */ + +import type { SentryContext } from "../../context.js"; +import { + fetchSentryStatus, + SENTRY_STATUS_PAGE_URL, +} from "../../lib/api/status-page.js"; +import { buildCommand } from "../../lib/command.js"; +import { formatSentryStatus } from "../../lib/formatters/human.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; + +type ShowFlags = { + readonly json: boolean; + readonly url: string; + readonly fields?: string[]; +}; + +export const showCommand = buildCommand({ + // Checking the public status page requires no Sentry credentials. + auth: false, + docs: { + brief: "Show Sentry service status", + fullDescription: + "Report the current status of Sentry's services using the public " + + "status page (https://status.sentry.io) as the backend.\n\n" + + "This works even when the Sentry API is degraded or unreachable, so " + + "you can tell whether an issue is on Sentry's side. Point `--url` at a " + + "different Statuspage instance to check a self-hosted or regional page.", + }, + output: { human: formatSentryStatus }, + parameters: { + flags: { + url: { + kind: "parsed", + parse: String, + brief: "Status page base URL to query", + default: SENTRY_STATUS_PAGE_URL, + }, + }, + }, + async *func(this: SentryContext, flags: ShowFlags) { + const status = await fetchSentryStatus(flags.url); + yield new CommandOutput(status); + + if (status.indicator !== "none") { + return { + hint: `Run \`sentry status\` again to refresh, or open ${status.url}`, + }; + } + }, +}); diff --git a/packages/cli/src/lib/api/status-page.ts b/packages/cli/src/lib/api/status-page.ts new file mode 100644 index 000000000..42d28abe8 --- /dev/null +++ b/packages/cli/src/lib/api/status-page.ts @@ -0,0 +1,129 @@ +/** + * Statuspage.io API client for Sentry's public status page. + * + * Sentry's status page (https://status.sentry.io) is hosted on Statuspage.io, + * which exposes a stable JSON summary at `/api/v2/summary.json`. This module + * fetches and shapes that payload for the `sentry status` command. + * + * The request is bounded by an explicit timeout so a status check never hangs, + * which is the whole point of the command — it is meant to work even while the + * Sentry API itself is degraded. + */ + +import { ApiError } from "../errors.js"; + +/** Default Sentry status page base URL. */ +export const SENTRY_STATUS_PAGE_URL = "https://status.sentry.io"; + +/** Bound the request so a status check never hangs, even during an outage. */ +const STATUS_REQUEST_TIMEOUT_MS = 10_000; + +/** Matches one or more trailing slashes so base URLs normalize cleanly. */ +const TRAILING_SLASHES = /\/+$/; + +/** Overall indicator reported by Statuspage. */ +export type StatusIndicator = + | "none" + | "minor" + | "major" + | "critical" + | "maintenance"; + +/** Per-component operational status reported by Statuspage. */ +export type ComponentStatus = + | "operational" + | "degraded_performance" + | "partial_outage" + | "major_outage" + | "under_maintenance"; + +/** A single service component on the status page. */ +export type StatusComponent = { + readonly name: string; + readonly status: ComponentStatus; +}; + +/** An ongoing or recent incident. */ +export type StatusIncident = { + readonly name: string; + readonly status: string; + readonly impact: string; + readonly shortlink: string; +}; + +/** Structured status data: JSON output shape and human-formatter input. */ +export type SentryStatus = { + /** Overall indicator and human-readable description. */ + readonly indicator: StatusIndicator; + readonly description: string; + /** Status page URL the data was fetched from. */ + readonly url: string; + /** All service components with their current status. */ + readonly components: readonly StatusComponent[]; + /** Unresolved incidents currently shown on the status page. */ + readonly incidents: readonly StatusIncident[]; +}; + +/** Shape of the Statuspage `/api/v2/summary.json` response we consume. */ +type SummaryResponse = { + page?: { url?: string }; + status?: { indicator?: string; description?: string }; + components?: Array<{ name?: string; status?: string; group?: boolean }>; + incidents?: Array<{ + name?: string; + status?: string; + impact?: string; + shortlink?: string; + }>; +}; + +/** + * Fetch the current Sentry service status from a Statuspage summary endpoint. + * + * @param baseUrl - Status page base URL (defaults to status.sentry.io). Pass a + * custom URL to point at a self-hosted or regional Statuspage instance. + */ +export async function fetchSentryStatus( + baseUrl: string = SENTRY_STATUS_PAGE_URL +): Promise { + const normalized = baseUrl.replace(TRAILING_SLASHES, ""); + const endpoint = `${normalized}/api/v2/summary.json`; + + const response = await fetch(endpoint, { + signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new ApiError( + "Failed to fetch Sentry status", + response.status, + await response.text(), + endpoint + ); + } + + const summary = (await response.json()) as SummaryResponse; + + const components: StatusComponent[] = (summary.components ?? []) + // Group headers carry no operational status of their own. + .filter((c) => !c.group && typeof c.name === "string") + .map((c) => ({ + name: c.name as string, + status: (c.status as ComponentStatus) ?? "operational", + })); + + const incidents: StatusIncident[] = (summary.incidents ?? []).map((i) => ({ + name: i.name ?? "Unnamed incident", + status: i.status ?? "unknown", + impact: i.impact ?? "none", + shortlink: i.shortlink ?? normalized, + })); + + return { + indicator: (summary.status?.indicator as StatusIndicator) ?? "none", + description: summary.status?.description ?? "Unknown", + url: summary.page?.url ?? normalized, + components, + incidents, + }; +} diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index b59f50a4d..92153232a 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -2581,3 +2581,88 @@ export function formatDefaultsResult(data: DefaultsResult): string { return ""; } } + +// Sentry Service Status Formatting + +/** Structured service status data shape (re-imported from the API module) */ +type SentryStatus = import("../api/status-page.js").SentryStatus; +type StatusComponent = import("../api/status-page.js").StatusComponent; + +/** Color tag for the overall status indicator. */ +const STATUS_INDICATOR_TAGS: Record[0]> = { + none: "green", + minor: "yellow", + major: "red", + critical: "red", + maintenance: "blue", +}; + +/** Color tag for a component's operational status. */ +const COMPONENT_STATUS_TAGS: Record[0]> = { + operational: "green", + degraded_performance: "yellow", + partial_outage: "yellow", + major_outage: "red", + under_maintenance: "blue", +}; + +/** Human-readable label for a component's operational status. */ +const COMPONENT_STATUS_LABELS: Record = { + operational: "Operational", + degraded_performance: "Degraded Performance", + partial_outage: "Partial Outage", + major_outage: "Major Outage", + under_maintenance: "Under Maintenance", +}; + +/** Render one component row: a colored dot, its name, and status label. */ +function formatComponentLine(component: StatusComponent): string { + const tag = COMPONENT_STATUS_TAGS[component.status] ?? "yellow"; + const label = + COMPONENT_STATUS_LABELS[component.status] ?? capitalize(component.status); + return `${colorTag(tag, "●")} ${escapeMarkdownInline(component.name)} — ${label}`; +} + +/** + * Format Sentry service status as rendered markdown: an overall header, any + * active incidents, and the per-component breakdown. + */ +export function formatSentryStatus(data: SentryStatus): string { + const lines: string[] = []; + + const indicatorTag = STATUS_INDICATOR_TAGS[data.indicator] ?? "yellow"; + const icon = data.indicator === "none" ? "✓" : "●"; + lines.push( + `## ${colorTag(indicatorTag, icon)} ${escapeMarkdownInline(data.description)}` + ); + lines.push(""); + + if (data.incidents.length > 0) { + lines.push("### Active Incidents"); + lines.push(""); + for (const incident of data.incidents) { + lines.push( + `- **${escapeMarkdownInline(incident.name)}** (${escapeMarkdownInline(incident.impact)} impact, ${escapeMarkdownInline(incident.status)})` + ); + lines.push(` ${safeCodeSpan(incident.shortlink)}`); + } + lines.push(""); + } + + if (data.components.length > 0) { + // Only surface components that aren't fully operational to keep the output + // focused during an outage; fall back to the full list when all is well. + const impacted = data.components.filter((c) => c.status !== "operational"); + const shown = impacted.length > 0 ? impacted : data.components; + lines.push("### Components"); + lines.push(""); + for (const component of shown) { + lines.push(formatComponentLine(component)); + } + lines.push(""); + } + + lines.push(`See ${safeCodeSpan(data.url)} for full details.`); + + return renderMarkdown(lines.join("\n")); +} diff --git a/packages/cli/test/commands/status/show.test.ts b/packages/cli/test/commands/status/show.test.ts new file mode 100644 index 000000000..5c13dbab6 --- /dev/null +++ b/packages/cli/test/commands/status/show.test.ts @@ -0,0 +1,144 @@ +/** + * Status Command Tests + * + * Tests for the showCommand func() in src/commands/status/show.ts. + * Mocks globalThis.fetch to return canned Statuspage summary payloads and + * asserts on both human and --json output. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { showCommand } from "../../../src/commands/status/show.js"; +import { ApiError } from "../../../src/lib/errors.js"; + +type ShowFlags = { + readonly json: boolean; + readonly url: string; +}; +type ShowFunc = (this: unknown, flags: ShowFlags) => Promise; + +/** Store original fetch for restoration. */ +let originalFetch: typeof globalThis.fetch; + +function mockFetch(payload: unknown, ok = true, status = 200): void { + globalThis.fetch = (async () => + ({ + ok, + status, + json: async () => payload, + text: async () => JSON.stringify(payload), + }) as unknown as Response) as typeof globalThis.fetch; +} + +/** Create a mock Stricli context with stdout capture. */ +function createContext() { + const stdoutChunks: string[] = []; + return { + context: { + stdout: { + write: vi.fn((s: string) => { + stdoutChunks.push(s); + }), + }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + getOutput: () => stdoutChunks.join(""), + }; +} + +const OPERATIONAL_SUMMARY = { + page: { url: "https://status.sentry.io" }, + status: { indicator: "none", description: "All Systems Operational" }, + components: [ + { name: "Dashboard", status: "operational", group: false }, + { name: "Group Header", status: "operational", group: true }, + ], + incidents: [], +}; + +const OUTAGE_SUMMARY = { + page: { url: "https://status.sentry.io" }, + status: { indicator: "major", description: "Major Service Outage" }, + components: [ + { name: "Dashboard", status: "partial_outage", group: false }, + { name: "Slack", status: "operational", group: false }, + ], + incidents: [ + { + name: "sentry.io is not available", + status: "investigating", + impact: "major", + shortlink: "https://stspg.io/abc123", + }, + ], +}; + +describe("showCommand.func", () => { + let func: ShowFunc; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + func = (await showCommand.loader()) as unknown as ShowFunc; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + const humanFlags: ShowFlags = { + json: false, + url: "https://status.sentry.io", + }; + const jsonFlags: ShowFlags = { json: true, url: "https://status.sentry.io" }; + + test("renders operational status and drops group headers", async () => { + mockFetch(OPERATIONAL_SUMMARY); + const { context, getOutput } = createContext(); + + await func.call(context, humanFlags); + + const out = getOutput(); + expect(out).toContain("All Systems Operational"); + expect(out).toContain("Dashboard"); + // Group entries are filtered out of the component list. + expect(out).not.toContain("Group Header"); + }); + + test("renders incidents and impacted components during an outage", async () => { + mockFetch(OUTAGE_SUMMARY); + const { context, getOutput } = createContext(); + + await func.call(context, humanFlags); + + const out = getOutput(); + expect(out).toContain("Major Service Outage"); + expect(out).toContain("Active Incidents"); + expect(out).toContain("sentry.io is not available"); + expect(out).toContain("Dashboard"); + // Operational components are hidden when there is at least one impacted one. + expect(out).not.toContain("Slack"); + }); + + test("emits structured JSON with --json", async () => { + mockFetch(OUTAGE_SUMMARY); + const { context, getOutput } = createContext(); + + await func.call(context, jsonFlags); + + const parsed = JSON.parse(getOutput()); + expect(parsed.indicator).toBe("major"); + expect(parsed.description).toBe("Major Service Outage"); + expect(parsed.components).toHaveLength(2); + expect(parsed.incidents[0].name).toBe("sentry.io is not available"); + }); + + test("throws ApiError on a non-ok response", async () => { + mockFetch({}, false, 503); + const { context } = createContext(); + + await expect(func.call(context, humanFlags)).rejects.toBeInstanceOf( + ApiError + ); + }); +}); From b185f433b250d70426c1aa1e8e83c0751c5ee352 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 11:08:31 +0000 Subject: [PATCH 02/10] test: register status group in completions property test The completions property test hardcodes which command groups have a defaultCommand (they propose flags rather than subcommand names for completion). The new `status` group has `defaultCommand: "show"`, so add it to that set. --- packages/cli/test/lib/completions.property.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/test/lib/completions.property.test.ts b/packages/cli/test/lib/completions.property.test.ts index d3a8ece63..098b7aa4e 100644 --- a/packages/cli/test/lib/completions.property.test.ts +++ b/packages/cli/test/lib/completions.property.test.ts @@ -195,6 +195,7 @@ describe("proposeCompletions: Stricli integration", () => { "docs", "trace", "span", + "status", "log", "local", "monitor", From 0b933071f61b81f4a39a6a43e0b071a55910ecc7 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 11:21:43 +0000 Subject: [PATCH 03/10] feat(status): support self-hosted /_health/ probe Extends fetchSentryStatus to probe /_health/ for non-statuspage hosts while keeping the Statuspage summary flow for status.sentry.io. Fixes the original prioritization of the generic health endpoint. --- packages/cli/src/lib/api/status-page.ts | 122 ++++++++++++------ packages/cli/test/lib/api/status-page.test.ts | 40 ++++++ 2 files changed, 126 insertions(+), 36 deletions(-) create mode 100644 packages/cli/test/lib/api/status-page.test.ts diff --git a/packages/cli/src/lib/api/status-page.ts b/packages/cli/src/lib/api/status-page.ts index 42d28abe8..697dc946d 100644 --- a/packages/cli/src/lib/api/status-page.ts +++ b/packages/cli/src/lib/api/status-page.ts @@ -11,6 +11,7 @@ */ import { ApiError } from "../errors.js"; +import { customFetch } from "../custom-ca.js"; /** Default Sentry status page base URL. */ export const SENTRY_STATUS_PAGE_URL = "https://status.sentry.io"; @@ -87,43 +88,92 @@ export async function fetchSentryStatus( baseUrl: string = SENTRY_STATUS_PAGE_URL ): Promise { const normalized = baseUrl.replace(TRAILING_SLASHES, ""); - const endpoint = `${normalized}/api/v2/summary.json`; - - const response = await fetch(endpoint, { - signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), - }); - - if (!response.ok) { - throw new ApiError( - "Failed to fetch Sentry status", - response.status, - await response.text(), - endpoint - ); - } - - const summary = (await response.json()) as SummaryResponse; - const components: StatusComponent[] = (summary.components ?? []) - // Group headers carry no operational status of their own. - .filter((c) => !c.group && typeof c.name === "string") - .map((c) => ({ - name: c.name as string, - status: (c.status as ComponentStatus) ?? "operational", + // Statuspage hosts (statuspage.io) use the /api/v2/summary.json flow. + // All other hosts (self-hosted) are probed via the generic /_health/ endpoint. + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(normalized); + } catch { + parsedUrl = undefined; + } + const isStatuspageHost = parsedUrl + ? parsedUrl.hostname.endsWith("statuspage.io") + : false; + + if (isStatuspageHost) { + const endpoint = `${normalized}/api/v2/summary.json`; + + const response = await customFetch(endpoint, { + signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new ApiError( + "Failed to fetch Sentry status", + response.status, + await response.text(), + endpoint + ); + } + + const summary = (await response.json()) as SummaryResponse; + + const components: StatusComponent[] = (summary.components ?? []) + // Group headers carry no operational status of their own. + .filter((c) => !c.group && typeof c.name === "string") + .map((c) => ({ + name: c.name as string, + status: (c.status as ComponentStatus) ?? "operational", + })); + + const incidents: StatusIncident[] = (summary.incidents ?? []).map((i) => ({ + name: i.name ?? "Unnamed incident", + status: i.status ?? "unknown", + impact: i.impact ?? "none", + shortlink: i.shortlink ?? normalized, })); - const incidents: StatusIncident[] = (summary.incidents ?? []).map((i) => ({ - name: i.name ?? "Unnamed incident", - status: i.status ?? "unknown", - impact: i.impact ?? "none", - shortlink: i.shortlink ?? normalized, - })); - - return { - indicator: (summary.status?.indicator as StatusIndicator) ?? "none", - description: summary.status?.description ?? "Unknown", - url: summary.page?.url ?? normalized, - components, - incidents, - }; + return { + indicator: (summary.status?.indicator as StatusIndicator) ?? "none", + description: summary.status?.description ?? "Unknown", + url: summary.page?.url ?? normalized, + components, + incidents, + }; + } + + // Self-hosted fallback: probe /_health/ (never throws; returns synthetic status). + const healthEndpoint = `${normalized}/_health/`; + try { + const resp = await customFetch(healthEndpoint, { + signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), + }); + + if (resp.ok) { + return { + indicator: "none", + description: resp.statusText || "OK", + url: normalized, + components: [], + incidents: [], + }; + } + + return { + indicator: "major", + description: resp.statusText || `HTTP ${resp.status}`, + url: normalized, + components: [], + incidents: [], + }; + } catch (err) { + return { + indicator: "major", + description: err instanceof Error ? err.message : String(err), + url: normalized, + components: [], + incidents: [], + }; + } } diff --git a/packages/cli/test/lib/api/status-page.test.ts b/packages/cli/test/lib/api/status-page.test.ts new file mode 100644 index 000000000..67a0b5698 --- /dev/null +++ b/packages/cli/test/lib/api/status-page.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { fetchSentryStatus } from "../../../src/lib/api/status-page.js"; + +const { customFetchMock } = vi.hoisted(() => ({ customFetchMock: vi.fn() })); +vi.mock("../../../src/lib/custom-ca.js", () => ({ customFetch: customFetchMock })); + +beforeEach(() => { + customFetchMock.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("self-hosted URL probes /_health/ and returns operational (none) on 200", async () => { + customFetchMock.mockResolvedValue( + new Response("", { status: 200, statusText: "OK" }) + ); + + const status = await fetchSentryStatus("https://example.com"); + + expect(status.indicator).toBe("none"); + expect(status.url).toBe("https://example.com"); + + const [calledUrl, calledInit] = customFetchMock.mock.calls[0] ?? []; + expect(calledUrl).toBe("https://example.com/_health/"); + expect(calledInit).toHaveProperty("signal"); +}); + +test("self-hosted URL reports major on non-2xx", async () => { + customFetchMock.mockResolvedValue( + new Response("", { status: 503, statusText: "Service Unavailable" }) + ); + + const status = await fetchSentryStatus("https://self.sentry.local"); + + expect(status.indicator).toBe("major"); + expect(status.description).toContain("Service Unavailable"); +}); \ No newline at end of file From 8cf5255cd4cd4521f95edd9a01ecaa41f622c431 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 11:30:36 +0000 Subject: [PATCH 04/10] fix(status): route status.sentry.io to Statuspage summary + fix lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-hosted /_health/ change regressed the default flow: status.sentry.io does not end with "statuspage.io", so `sentry status` fell through to the health probe and lost the components/incidents summary. Match the public status host explicitly (status.sentry.io or *.statuspage.io) — this also tightens the host check the CodeQL substring-sanitization alert flagged. Also splits fetchSentryStatus into fetchStatuspageSummary/probeSelfHostedHealth to drop under the cognitive-complexity limit and fixes import ordering. Adds a regression test that the default URL hits /api/v2/summary.json. --- packages/cli/src/lib/api/status-page.ts | 117 ++++++++++-------- packages/cli/test/lib/api/status-page.test.ts | 23 +++- 2 files changed, 84 insertions(+), 56 deletions(-) diff --git a/packages/cli/src/lib/api/status-page.ts b/packages/cli/src/lib/api/status-page.ts index 697dc946d..ac5458d83 100644 --- a/packages/cli/src/lib/api/status-page.ts +++ b/packages/cli/src/lib/api/status-page.ts @@ -10,8 +10,8 @@ * Sentry API itself is degraded. */ -import { ApiError } from "../errors.js"; import { customFetch } from "../custom-ca.js"; +import { ApiError } from "../errors.js"; /** Default Sentry status page base URL. */ export const SENTRY_STATUS_PAGE_URL = "https://status.sentry.io"; @@ -84,7 +84,7 @@ type SummaryResponse = { * @param baseUrl - Status page base URL (defaults to status.sentry.io). Pass a * custom URL to point at a self-hosted or regional Statuspage instance. */ -export async function fetchSentryStatus( +export function fetchSentryStatus( baseUrl: string = SENTRY_STATUS_PAGE_URL ): Promise { const normalized = baseUrl.replace(TRAILING_SLASHES, ""); @@ -97,72 +97,81 @@ export async function fetchSentryStatus( } catch { parsedUrl = undefined; } - const isStatuspageHost = parsedUrl - ? parsedUrl.hostname.endsWith("statuspage.io") + const host = parsedUrl?.hostname.toLowerCase(); + // Sentry's public status page (status.sentry.io) is a Statuspage instance, + // as are any *.statuspage.io hosts. Everything else is treated as a + // self-hosted Sentry and probed via /_health/. + const isStatuspageHost = host + ? host === "status.sentry.io" || host.endsWith(".statuspage.io") : false; - if (isStatuspageHost) { - const endpoint = `${normalized}/api/v2/summary.json`; + return isStatuspageHost + ? fetchStatuspageSummary(normalized) + : probeSelfHostedHealth(normalized); +} - const response = await customFetch(endpoint, { - signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), - }); +/** Fetch and shape a Statuspage `/api/v2/summary.json` response. */ +async function fetchStatuspageSummary( + normalized: string +): Promise { + const endpoint = `${normalized}/api/v2/summary.json`; + + const response = await customFetch(endpoint, { + signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new ApiError( + "Failed to fetch Sentry status", + response.status, + await response.text(), + endpoint + ); + } - if (!response.ok) { - throw new ApiError( - "Failed to fetch Sentry status", - response.status, - await response.text(), - endpoint - ); - } - - const summary = (await response.json()) as SummaryResponse; - - const components: StatusComponent[] = (summary.components ?? []) - // Group headers carry no operational status of their own. - .filter((c) => !c.group && typeof c.name === "string") - .map((c) => ({ - name: c.name as string, - status: (c.status as ComponentStatus) ?? "operational", - })); - - const incidents: StatusIncident[] = (summary.incidents ?? []).map((i) => ({ - name: i.name ?? "Unnamed incident", - status: i.status ?? "unknown", - impact: i.impact ?? "none", - shortlink: i.shortlink ?? normalized, + const summary = (await response.json()) as SummaryResponse; + + const components: StatusComponent[] = (summary.components ?? []) + // Group headers carry no operational status of their own. + .filter((c) => !c.group && typeof c.name === "string") + .map((c) => ({ + name: c.name as string, + status: (c.status as ComponentStatus) ?? "operational", })); - return { - indicator: (summary.status?.indicator as StatusIndicator) ?? "none", - description: summary.status?.description ?? "Unknown", - url: summary.page?.url ?? normalized, - components, - incidents, - }; - } + const incidents: StatusIncident[] = (summary.incidents ?? []).map((i) => ({ + name: i.name ?? "Unnamed incident", + status: i.status ?? "unknown", + impact: i.impact ?? "none", + shortlink: i.shortlink ?? normalized, + })); + + return { + indicator: (summary.status?.indicator as StatusIndicator) ?? "none", + description: summary.status?.description ?? "Unknown", + url: summary.page?.url ?? normalized, + components, + incidents, + }; +} - // Self-hosted fallback: probe /_health/ (never throws; returns synthetic status). +/** + * Probe a self-hosted Sentry's `/_health/` endpoint. Never throws — network or + * HTTP failures are reported as a synthetic "major" status so the command can + * still render something useful when the instance is down. + */ +async function probeSelfHostedHealth( + normalized: string +): Promise { const healthEndpoint = `${normalized}/_health/`; try { const resp = await customFetch(healthEndpoint, { signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), }); - if (resp.ok) { - return { - indicator: "none", - description: resp.statusText || "OK", - url: normalized, - components: [], - incidents: [], - }; - } - return { - indicator: "major", - description: resp.statusText || `HTTP ${resp.status}`, + indicator: resp.ok ? "none" : "major", + description: resp.statusText || (resp.ok ? "OK" : `HTTP ${resp.status}`), url: normalized, components: [], incidents: [], diff --git a/packages/cli/test/lib/api/status-page.test.ts b/packages/cli/test/lib/api/status-page.test.ts index 67a0b5698..275a148b4 100644 --- a/packages/cli/test/lib/api/status-page.test.ts +++ b/packages/cli/test/lib/api/status-page.test.ts @@ -3,7 +3,9 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { fetchSentryStatus } from "../../../src/lib/api/status-page.js"; const { customFetchMock } = vi.hoisted(() => ({ customFetchMock: vi.fn() })); -vi.mock("../../../src/lib/custom-ca.js", () => ({ customFetch: customFetchMock })); +vi.mock("../../../src/lib/custom-ca.js", () => ({ + customFetch: customFetchMock, +})); beforeEach(() => { customFetchMock.mockReset(); @@ -37,4 +39,21 @@ test("self-hosted URL reports major on non-2xx", async () => { expect(status.indicator).toBe("major"); expect(status.description).toContain("Service Unavailable"); -}); \ No newline at end of file +}); + +test("default status.sentry.io uses the Statuspage summary endpoint", async () => { + customFetchMock.mockResolvedValue( + Response.json({ + page: { url: "https://status.sentry.io" }, + status: { indicator: "none", description: "All Systems Operational" }, + components: [], + incidents: [], + }) + ); + + const status = await fetchSentryStatus(); + + expect(status.description).toBe("All Systems Operational"); + const [calledUrl] = customFetchMock.mock.calls[0] ?? []; + expect(calledUrl).toBe("https://status.sentry.io/api/v2/summary.json"); +}); From 4aa739cffe2b6d08b94316f4cdd7508a1b27c89b Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 11:57:27 +0000 Subject: [PATCH 05/10] feat(status): use 5 s timeout for /_health/ probe Sentry's own https://sentry.io/_health/ is now covered by the generic probe path; a shorter timeout keeps the status command snappy. --- packages/cli/src/lib/api/status-page.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/api/status-page.ts b/packages/cli/src/lib/api/status-page.ts index ac5458d83..762001980 100644 --- a/packages/cli/src/lib/api/status-page.ts +++ b/packages/cli/src/lib/api/status-page.ts @@ -19,6 +19,9 @@ export const SENTRY_STATUS_PAGE_URL = "https://status.sentry.io"; /** Bound the request so a status check never hangs, even during an outage. */ const STATUS_REQUEST_TIMEOUT_MS = 10_000; +/** Shorter timeout for the lightweight /_health/ probe (self-hosted or sentry.io). */ +const HEALTH_REQUEST_TIMEOUT_MS = 5_000; + /** Matches one or more trailing slashes so base URLs normalize cleanly. */ const TRAILING_SLASHES = /\/+$/; @@ -166,7 +169,7 @@ async function probeSelfHostedHealth( const healthEndpoint = `${normalized}/_health/`; try { const resp = await customFetch(healthEndpoint, { - signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS), }); return { From 635f09e00a8dd15b3068ddd163ca32a4bce07ea6 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 14:36:53 +0000 Subject: [PATCH 06/10] fix(status): drop digit separator on 4-digit health timeout biome useNumericSeparators flags grouping on 5_000; write it as 5000. --- packages/cli/src/lib/api/status-page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/api/status-page.ts b/packages/cli/src/lib/api/status-page.ts index 762001980..ce772d2fb 100644 --- a/packages/cli/src/lib/api/status-page.ts +++ b/packages/cli/src/lib/api/status-page.ts @@ -20,7 +20,7 @@ export const SENTRY_STATUS_PAGE_URL = "https://status.sentry.io"; const STATUS_REQUEST_TIMEOUT_MS = 10_000; /** Shorter timeout for the lightweight /_health/ probe (self-hosted or sentry.io). */ -const HEALTH_REQUEST_TIMEOUT_MS = 5_000; +const HEALTH_REQUEST_TIMEOUT_MS = 5000; /** Matches one or more trailing slashes so base URLs normalize cleanly. */ const TRAILING_SLASHES = /\/+$/; From f5c636440a4c8cfdd5b24c4fa14e55356e6488fe Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 14:52:38 +0000 Subject: [PATCH 07/10] fix(status): treat any host containing "status" as Statuspage Bugbot pointed out that a custom Statuspage CNAME (status.acme.com) would incorrectly fall through to the self-hosted /_health/ probe. Relax the host-detection heuristic to also match any hostname containing "status" while still excluding obvious non-Statuspage hosts. Updated the doc example and added a regression test. --- apps/cli-docs/src/fragments/commands/status.md | 4 ++-- packages/cli/src/lib/api/status-page.ts | 14 ++++++++------ packages/cli/test/lib/api/status-page.test.ts | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/status.md b/apps/cli-docs/src/fragments/commands/status.md index 18e78641a..40b5e6639 100644 --- a/apps/cli-docs/src/fragments/commands/status.md +++ b/apps/cli-docs/src/fragments/commands/status.md @@ -24,6 +24,6 @@ sentry status --json ``` ```bash -# Check a self-hosted or regional status page -sentry status --url https://status.example.com +# Check a self-hosted or regional status page (Statuspage CNAME) +sentry status --url https://status.acme.com ``` diff --git a/packages/cli/src/lib/api/status-page.ts b/packages/cli/src/lib/api/status-page.ts index ce772d2fb..f3587429b 100644 --- a/packages/cli/src/lib/api/status-page.ts +++ b/packages/cli/src/lib/api/status-page.ts @@ -100,13 +100,15 @@ export function fetchSentryStatus( } catch { parsedUrl = undefined; } - const host = parsedUrl?.hostname.toLowerCase(); + const host = parsedUrl?.hostname.toLowerCase() ?? ""; // Sentry's public status page (status.sentry.io) is a Statuspage instance, - // as are any *.statuspage.io hosts. Everything else is treated as a - // self-hosted Sentry and probed via /_health/. - const isStatuspageHost = host - ? host === "status.sentry.io" || host.endsWith(".statuspage.io") - : false; + // as are any *.statuspage.io hosts and common CNAMEs containing "status" + // (status.example.com, statuspage.acme.com, …). Everything else falls back + // to the lightweight self-hosted /_health/ probe. + const isStatuspageHost = + host === "status.sentry.io" || + host.endsWith(".statuspage.io") || + host.includes("status"); return isStatuspageHost ? fetchStatuspageSummary(normalized) diff --git a/packages/cli/test/lib/api/status-page.test.ts b/packages/cli/test/lib/api/status-page.test.ts index 275a148b4..8c358e109 100644 --- a/packages/cli/test/lib/api/status-page.test.ts +++ b/packages/cli/test/lib/api/status-page.test.ts @@ -57,3 +57,20 @@ test("default status.sentry.io uses the Statuspage summary endpoint", async () = const [calledUrl] = customFetchMock.mock.calls[0] ?? []; expect(calledUrl).toBe("https://status.sentry.io/api/v2/summary.json"); }); + +test("custom Statuspage CNAME uses the summary endpoint", async () => { + customFetchMock.mockResolvedValue( + Response.json({ + page: { url: "https://status.acme.com" }, + status: { indicator: "minor", description: "Minor Service Outage" }, + components: [], + incidents: [], + }) + ); + + const status = await fetchSentryStatus("https://status.acme.com"); + + expect(status.indicator).toBe("minor"); + const [calledUrl] = customFetchMock.mock.calls[0] ?? []; + expect(calledUrl).toBe("https://status.acme.com/api/v2/summary.json"); +}); From e511ac19a825f555ab819ea5519bdeb86aa40d9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 27 Aug 2026 14:53:24 +0000 Subject: [PATCH 08/10] chore: regenerate docs --- .../plugins/sentry-cli/skills/sentry-cli/references/status.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md index f7ad4d438..7d03e3154 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md @@ -27,8 +27,8 @@ sentry status # Get machine-readable status (useful in scripts) sentry status --json -# Check a self-hosted or regional status page -sentry status --url https://status.example.com +# Check a self-hosted or regional status page (Statuspage CNAME) +sentry status --url https://status.acme.com ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. From 7e03ef3b8966dfdd82e1ee843c940ba5afa1d891 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 14:57:17 +0000 Subject: [PATCH 09/10] fix(status): swallow non-ApiError failures from misclassified hosts Bugbot pointed out that a self-hosted host containing "status" would be misclassified, call fetchStatuspageSummary, and throw an unhandled ApiError. Catch any non-ApiError (network/JSON errors) and fall back to the synthetic health status; explicit ApiErrors still propagate so tests and CLI error handling remain unchanged. --- packages/cli/src/lib/api/status-page.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/api/status-page.ts b/packages/cli/src/lib/api/status-page.ts index f3587429b..ee39d22a1 100644 --- a/packages/cli/src/lib/api/status-page.ts +++ b/packages/cli/src/lib/api/status-page.ts @@ -87,7 +87,7 @@ type SummaryResponse = { * @param baseUrl - Status page base URL (defaults to status.sentry.io). Pass a * custom URL to point at a self-hosted or regional Statuspage instance. */ -export function fetchSentryStatus( +export async function fetchSentryStatus( baseUrl: string = SENTRY_STATUS_PAGE_URL ): Promise { const normalized = baseUrl.replace(TRAILING_SLASHES, ""); @@ -110,9 +110,21 @@ export function fetchSentryStatus( host.endsWith(".statuspage.io") || host.includes("status"); - return isStatuspageHost - ? fetchStatuspageSummary(normalized) - : probeSelfHostedHealth(normalized); + if (isStatuspageHost) { + try { + return await fetchStatuspageSummary(normalized); + } catch (err) { + // Swallow only *unexpected* failures (network, JSON parse, etc.). + // Explicit ApiError responses (4xx/5xx) are still reported to the + // caller so the existing test expectations and CLI error handling + // continue to work. + if (!(err instanceof ApiError)) { + return probeSelfHostedHealth(normalized); + } + throw err; + } + } + return probeSelfHostedHealth(normalized); } /** Fetch and shape a Statuspage `/api/v2/summary.json` response. */ From 9ec17f2e9fd34b4a679a0b384f4b749b10bb81db Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 19:13:59 +0000 Subject: [PATCH 10/10] fix(status): surface subsystem failures + drop false-outage fallback - probe /_health/?full=1 so Postgres/Redis/Celery outages fail the check instead of returning 200 for a live web process only - route strictly by host: Statuspage hosts hit the summary API, everything else the health probe; no more health-probing a Statuspage base URL on transient errors (which produced spurious 'major' after a 5s wait) --- packages/cli/src/lib/api/status-page.ts | 26 +++++++------------ packages/cli/test/lib/api/status-page.test.ts | 2 +- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/lib/api/status-page.ts b/packages/cli/src/lib/api/status-page.ts index ee39d22a1..5b985a057 100644 --- a/packages/cli/src/lib/api/status-page.ts +++ b/packages/cli/src/lib/api/status-page.ts @@ -87,7 +87,7 @@ type SummaryResponse = { * @param baseUrl - Status page base URL (defaults to status.sentry.io). Pass a * custom URL to point at a self-hosted or regional Statuspage instance. */ -export async function fetchSentryStatus( +export function fetchSentryStatus( baseUrl: string = SENTRY_STATUS_PAGE_URL ): Promise { const normalized = baseUrl.replace(TRAILING_SLASHES, ""); @@ -110,21 +110,9 @@ export async function fetchSentryStatus( host.endsWith(".statuspage.io") || host.includes("status"); - if (isStatuspageHost) { - try { - return await fetchStatuspageSummary(normalized); - } catch (err) { - // Swallow only *unexpected* failures (network, JSON parse, etc.). - // Explicit ApiError responses (4xx/5xx) are still reported to the - // caller so the existing test expectations and CLI error handling - // continue to work. - if (!(err instanceof ApiError)) { - return probeSelfHostedHealth(normalized); - } - throw err; - } - } - return probeSelfHostedHealth(normalized); + return isStatuspageHost + ? fetchStatuspageSummary(normalized) + : probeSelfHostedHealth(normalized); } /** Fetch and shape a Statuspage `/api/v2/summary.json` response. */ @@ -176,11 +164,15 @@ async function fetchStatuspageSummary( * Probe a self-hosted Sentry's `/_health/` endpoint. Never throws — network or * HTTP failures are reported as a synthetic "major" status so the command can * still render something useful when the instance is down. + * + * Uses `?full=1` so the check exercises every subsystem (Postgres, Redis, + * Celery, …) rather than the bare liveness probe: without it Sentry returns + * HTTP 200 whenever the web process is up, masking real backend outages. */ async function probeSelfHostedHealth( normalized: string ): Promise { - const healthEndpoint = `${normalized}/_health/`; + const healthEndpoint = `${normalized}/_health/?full=1`; try { const resp = await customFetch(healthEndpoint, { signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS), diff --git a/packages/cli/test/lib/api/status-page.test.ts b/packages/cli/test/lib/api/status-page.test.ts index 8c358e109..b0e0d5e6e 100644 --- a/packages/cli/test/lib/api/status-page.test.ts +++ b/packages/cli/test/lib/api/status-page.test.ts @@ -26,7 +26,7 @@ test("self-hosted URL probes /_health/ and returns operational (none) on 200", a expect(status.url).toBe("https://example.com"); const [calledUrl, calledInit] = customFetchMock.mock.calls[0] ?? []; - expect(calledUrl).toBe("https://example.com/_health/"); + expect(calledUrl).toBe("https://example.com/_health/?full=1"); expect(calledInit).toHaveProperty("signal"); });