Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
80 changes: 43 additions & 37 deletions packages/cli/src/lib/api/status-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<SentryStatus> {
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 `<baseUrl>/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<SentryStatus> {
): Promise<SentryStatus | undefined> {
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.
Expand Down
14 changes: 8 additions & 6 deletions packages/cli/test/commands/status/show.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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("●");
});
});
132 changes: 93 additions & 39 deletions packages/cli/test/lib/api/status-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html>not json</html>", { 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");
});
Loading