From 633b1f6c0d058276bc072fceb2a9d60701f05935 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sun, 30 Aug 2026 05:26:31 +0000 Subject: [PATCH 1/2] feat(status): probe Statuspage summary directly instead of host heuristic Replace the loose domain-name heuristic (host.includes("status"), endsWith(".statuspage.io")) with a direct probe: request /api/v2/summary.json and use it only when the response is a valid Statuspage summary, otherwise fall back to the self-hosted /_health/?full=1 probe. This eliminates host mis-classification and makes `sentry status` reliable for arbitrary self-hosted or regional deployments. Fixes #1509 --- packages/cli/src/lib/api/status-page.ts | 80 ++++++----- .../cli/test/commands/status/show.test.ts | 14 +- packages/cli/test/lib/api/status-page.test.ts | 132 ++++++++++++------ 3 files changed, 144 insertions(+), 82 deletions(-) diff --git a/packages/cli/src/lib/api/status-page.ts b/packages/cli/src/lib/api/status-page.ts index 5b985a057..00e497625 100644 --- a/packages/cli/src/lib/api/status-page.ts +++ b/packages/cli/src/lib/api/status-page.ts @@ -11,7 +11,6 @@ */ 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"; @@ -82,59 +81,66 @@ type SummaryResponse = { }; /** - * Fetch the current Sentry service status from a Statuspage summary endpoint. + * Fetch the current Sentry service status. + * + * Rather than guessing from the host name whether a URL is a Statuspage + * instance, we probe the target directly: request `/api/v2/summary.json` and, + * if it comes back as a valid Statuspage summary, use it. Any clear + * non-Statuspage response (non-2xx, non-JSON, or a body missing the summary + * shape) or a network error falls back to the self-hosted `/_health/?full=1` + * probe. This makes the command reliable for arbitrary self-hosted or regional + * deployments without domain-name inference. * * @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, ""); - // 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 host = parsedUrl?.hostname.toLowerCase() ?? ""; - // Sentry's public status page (status.sentry.io) is a Statuspage instance, - // 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) - : probeSelfHostedHealth(normalized); + const summary = await tryFetchStatuspageSummary(normalized); + return summary ?? probeSelfHostedHealth(normalized); } -/** Fetch and shape a Statuspage `/api/v2/summary.json` response. */ -async function fetchStatuspageSummary( +/** + * Probe `/api/v2/summary.json` and shape it into a {@link SentryStatus}. + * + * Returns `undefined` when the target does not look like a Statuspage instance + * — a non-2xx response, a body that isn't JSON, or JSON that lacks the summary + * shape — so the caller can fall back to the self-hosted health probe. Never + * throws. + */ +async function tryFetchStatuspageSummary( normalized: string -): Promise { +): Promise { const endpoint = `${normalized}/api/v2/summary.json`; - const response = await customFetch(endpoint, { - signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), - }); + let response: Response; + try { + response = await customFetch(endpoint, { + signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS), + }); + } catch { + return; + } if (!response.ok) { - throw new ApiError( - "Failed to fetch Sentry status", - response.status, - await response.text(), - endpoint - ); + return; + } + + let summary: SummaryResponse; + try { + summary = (await response.json()) as SummaryResponse; + } catch { + return; } - const summary = (await response.json()) as SummaryResponse; + // A genuine Statuspage summary always carries a `status` object with an + // `indicator`. Anything else is some other JSON endpoint, not Statuspage. + if (!summary || typeof summary.status?.indicator !== "string") { + return; + } const components: StatusComponent[] = (summary.components ?? []) // Group headers carry no operational status of their own. diff --git a/packages/cli/test/commands/status/show.test.ts b/packages/cli/test/commands/status/show.test.ts index 5c13dbab6..81ed99abc 100644 --- a/packages/cli/test/commands/status/show.test.ts +++ b/packages/cli/test/commands/status/show.test.ts @@ -8,7 +8,6 @@ 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; @@ -133,12 +132,15 @@ describe("showCommand.func", () => { expect(parsed.incidents[0].name).toBe("sentry.io is not available"); }); - test("throws ApiError on a non-ok response", async () => { + test("falls back to a self-hosted health probe when summary.json is unavailable", async () => { + // Both the summary probe and the health fallback return non-2xx, so the + // command degrades to a synthetic "major" status rather than throwing. mockFetch({}, false, 503); - const { context } = createContext(); + const { context, getOutput } = createContext(); + + await func.call(context, humanFlags); - await expect(func.call(context, humanFlags)).rejects.toBeInstanceOf( - ApiError - ); + const out = getOutput(); + expect(out).toContain("●"); }); }); diff --git a/packages/cli/test/lib/api/status-page.test.ts b/packages/cli/test/lib/api/status-page.test.ts index b0e0d5e6e..36c2d5c41 100644 --- a/packages/cli/test/lib/api/status-page.test.ts +++ b/packages/cli/test/lib/api/status-page.test.ts @@ -15,62 +15,116 @@ afterEach(() => { vi.restoreAllMocks(); }); -test("self-hosted URL probes /_health/ and returns operational (none) on 200", async () => { +/** A minimal, valid Statuspage summary payload. */ +function summaryResponse( + indicator: string, + description: string, + pageUrl: string +): Response { + return Response.json({ + page: { url: pageUrl }, + status: { indicator, description }, + components: [], + incidents: [], + }); +} + +test("probes summary.json first and uses it when the target is Statuspage", async () => { customFetchMock.mockResolvedValue( - new Response("", { status: 200, statusText: "OK" }) + summaryResponse( + "none", + "All Systems Operational", + "https://status.sentry.io" + ) ); - const status = await fetchSentryStatus("https://example.com"); - - expect(status.indicator).toBe("none"); - expect(status.url).toBe("https://example.com"); + const status = await fetchSentryStatus(); - const [calledUrl, calledInit] = customFetchMock.mock.calls[0] ?? []; - expect(calledUrl).toBe("https://example.com/_health/?full=1"); - expect(calledInit).toHaveProperty("signal"); + expect(status.description).toBe("All Systems Operational"); + // Exactly one request: the summary probe succeeded, no health fallback. + expect(customFetchMock).toHaveBeenCalledTimes(1); + const [calledUrl] = customFetchMock.mock.calls[0] ?? []; + expect(calledUrl).toBe("https://status.sentry.io/api/v2/summary.json"); }); -test("self-hosted URL reports major on non-2xx", async () => { +test("uses summary.json for an arbitrary host that responds like Statuspage", async () => { customFetchMock.mockResolvedValue( - new Response("", { status: 503, statusText: "Service Unavailable" }) + summaryResponse( + "minor", + "Minor Service Outage", + "https://sentry.example.com" + ) ); + const status = await fetchSentryStatus("https://sentry.example.com"); + + expect(status.indicator).toBe("minor"); + expect(customFetchMock).toHaveBeenCalledTimes(1); + const [calledUrl] = customFetchMock.mock.calls[0] ?? []; + expect(calledUrl).toBe("https://sentry.example.com/api/v2/summary.json"); +}); + +test("falls back to /_health/ when summary.json is not found (404)", async () => { + customFetchMock + .mockResolvedValueOnce(new Response("Not Found", { status: 404 })) + .mockResolvedValueOnce(new Response("", { status: 200, statusText: "OK" })); + const status = await fetchSentryStatus("https://self.sentry.local"); - expect(status.indicator).toBe("major"); - expect(status.description).toContain("Service Unavailable"); + expect(status.indicator).toBe("none"); + expect(status.url).toBe("https://self.sentry.local"); + expect(customFetchMock).toHaveBeenCalledTimes(2); + const [summaryUrl] = customFetchMock.mock.calls[0] ?? []; + const [healthUrl, healthInit] = customFetchMock.mock.calls[1] ?? []; + expect(summaryUrl).toBe("https://self.sentry.local/api/v2/summary.json"); + expect(healthUrl).toBe("https://self.sentry.local/_health/?full=1"); + expect(healthInit).toHaveProperty("signal"); }); -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: [], - }) - ); +test("falls back to /_health/ when summary.json returns non-Statuspage JSON", async () => { + customFetchMock + .mockResolvedValueOnce(Response.json({ hello: "world" })) + .mockResolvedValueOnce(new Response("", { status: 200, statusText: "OK" })); - const status = await fetchSentryStatus(); + const status = await fetchSentryStatus("https://self.sentry.local"); - expect(status.description).toBe("All Systems Operational"); - const [calledUrl] = customFetchMock.mock.calls[0] ?? []; - expect(calledUrl).toBe("https://status.sentry.io/api/v2/summary.json"); + expect(status.indicator).toBe("none"); + expect(customFetchMock).toHaveBeenCalledTimes(2); + const [healthUrl] = customFetchMock.mock.calls[1] ?? []; + expect(healthUrl).toBe("https://self.sentry.local/_health/?full=1"); }); -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: [], - }) - ); +test("falls back to /_health/ when summary.json is not JSON", async () => { + customFetchMock + .mockResolvedValueOnce( + new Response("not json", { status: 200 }) + ) + .mockResolvedValueOnce(new Response("", { status: 200, statusText: "OK" })); - const status = await fetchSentryStatus("https://status.acme.com"); + const status = await fetchSentryStatus("https://self.sentry.local"); - expect(status.indicator).toBe("minor"); - const [calledUrl] = customFetchMock.mock.calls[0] ?? []; - expect(calledUrl).toBe("https://status.acme.com/api/v2/summary.json"); + expect(status.indicator).toBe("none"); + expect(customFetchMock).toHaveBeenCalledTimes(2); +}); + +test("reports major when the health fallback returns non-2xx", async () => { + customFetchMock + .mockResolvedValueOnce(new Response("Not Found", { status: 404 })) + .mockResolvedValueOnce( + 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"); +}); + +test("reports major when both the summary probe and health probe throw", async () => { + customFetchMock.mockRejectedValue(new Error("network down")); + + const status = await fetchSentryStatus("https://self.sentry.local"); + + expect(status.indicator).toBe("major"); + expect(status.description).toContain("network down"); }); From 1f6b93c2fad82c5f0327594bbd595700bcecc783 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 30 Aug 2026 05:27:35 +0000 Subject: [PATCH 2/2] chore: regenerate docs --- .../plugins/sentry-cli/skills/sentry-cli/references/status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7d03e3154..4aa93c4c1 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 @@ -1,6 +1,6 @@ --- name: sentry-cli-status -version: 0.44.0-dev.0 +version: 0.45.0-dev.0 description: Check Sentry service status requires: bins: ["sentry"]