Skip to content

Commit 7852038

Browse files
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.
1 parent e65b9d2 commit 7852038

2 files changed

Lines changed: 126 additions & 36 deletions

File tree

packages/cli/src/lib/api/status-page.ts

Lines changed: 86 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import { ApiError } from "../errors.js";
14+
import { customFetch } from "../custom-ca.js";
1415

1516
/** Default Sentry status page base URL. */
1617
export const SENTRY_STATUS_PAGE_URL = "https://status.sentry.io";
@@ -87,43 +88,92 @@ export async function fetchSentryStatus(
8788
baseUrl: string = SENTRY_STATUS_PAGE_URL
8889
): Promise<SentryStatus> {
8990
const normalized = baseUrl.replace(TRAILING_SLASHES, "");
90-
const endpoint = `${normalized}/api/v2/summary.json`;
91-
92-
const response = await fetch(endpoint, {
93-
signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS),
94-
});
95-
96-
if (!response.ok) {
97-
throw new ApiError(
98-
"Failed to fetch Sentry status",
99-
response.status,
100-
await response.text(),
101-
endpoint
102-
);
103-
}
104-
105-
const summary = (await response.json()) as SummaryResponse;
10691

107-
const components: StatusComponent[] = (summary.components ?? [])
108-
// Group headers carry no operational status of their own.
109-
.filter((c) => !c.group && typeof c.name === "string")
110-
.map((c) => ({
111-
name: c.name as string,
112-
status: (c.status as ComponentStatus) ?? "operational",
92+
// Statuspage hosts (statuspage.io) use the /api/v2/summary.json flow.
93+
// All other hosts (self-hosted) are probed via the generic /_health/ endpoint.
94+
let parsedUrl: URL | undefined;
95+
try {
96+
parsedUrl = new URL(normalized);
97+
} catch {
98+
parsedUrl = undefined;
99+
}
100+
const isStatuspageHost = parsedUrl
101+
? parsedUrl.hostname.endsWith("statuspage.io")
102+
: false;
103+
104+
if (isStatuspageHost) {
105+
const endpoint = `${normalized}/api/v2/summary.json`;
106+
107+
const response = await customFetch(endpoint, {
108+
signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS),
109+
});
110+
111+
if (!response.ok) {
112+
throw new ApiError(
113+
"Failed to fetch Sentry status",
114+
response.status,
115+
await response.text(),
116+
endpoint
117+
);
118+
}
119+
120+
const summary = (await response.json()) as SummaryResponse;
121+
122+
const components: StatusComponent[] = (summary.components ?? [])
123+
// Group headers carry no operational status of their own.
124+
.filter((c) => !c.group && typeof c.name === "string")
125+
.map((c) => ({
126+
name: c.name as string,
127+
status: (c.status as ComponentStatus) ?? "operational",
128+
}));
129+
130+
const incidents: StatusIncident[] = (summary.incidents ?? []).map((i) => ({
131+
name: i.name ?? "Unnamed incident",
132+
status: i.status ?? "unknown",
133+
impact: i.impact ?? "none",
134+
shortlink: i.shortlink ?? normalized,
113135
}));
114136

115-
const incidents: StatusIncident[] = (summary.incidents ?? []).map((i) => ({
116-
name: i.name ?? "Unnamed incident",
117-
status: i.status ?? "unknown",
118-
impact: i.impact ?? "none",
119-
shortlink: i.shortlink ?? normalized,
120-
}));
121-
122-
return {
123-
indicator: (summary.status?.indicator as StatusIndicator) ?? "none",
124-
description: summary.status?.description ?? "Unknown",
125-
url: summary.page?.url ?? normalized,
126-
components,
127-
incidents,
128-
};
137+
return {
138+
indicator: (summary.status?.indicator as StatusIndicator) ?? "none",
139+
description: summary.status?.description ?? "Unknown",
140+
url: summary.page?.url ?? normalized,
141+
components,
142+
incidents,
143+
};
144+
}
145+
146+
// Self-hosted fallback: probe /_health/ (never throws; returns synthetic status).
147+
const healthEndpoint = `${normalized}/_health/`;
148+
try {
149+
const resp = await customFetch(healthEndpoint, {
150+
signal: AbortSignal.timeout(STATUS_REQUEST_TIMEOUT_MS),
151+
});
152+
153+
if (resp.ok) {
154+
return {
155+
indicator: "none",
156+
description: resp.statusText || "OK",
157+
url: normalized,
158+
components: [],
159+
incidents: [],
160+
};
161+
}
162+
163+
return {
164+
indicator: "major",
165+
description: resp.statusText || `HTTP ${resp.status}`,
166+
url: normalized,
167+
components: [],
168+
incidents: [],
169+
};
170+
} catch (err) {
171+
return {
172+
indicator: "major",
173+
description: err instanceof Error ? err.message : String(err),
174+
url: normalized,
175+
components: [],
176+
incidents: [],
177+
};
178+
}
129179
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { afterEach, beforeEach, expect, test, vi } from "vitest";
2+
3+
import { fetchSentryStatus } from "../../../src/lib/api/status-page.js";
4+
5+
const { customFetchMock } = vi.hoisted(() => ({ customFetchMock: vi.fn() }));
6+
vi.mock("../../../src/lib/custom-ca.js", () => ({ customFetch: customFetchMock }));
7+
8+
beforeEach(() => {
9+
customFetchMock.mockReset();
10+
});
11+
12+
afterEach(() => {
13+
vi.restoreAllMocks();
14+
});
15+
16+
test("self-hosted URL probes /_health/ and returns operational (none) on 200", async () => {
17+
customFetchMock.mockResolvedValue(
18+
new Response("", { status: 200, statusText: "OK" })
19+
);
20+
21+
const status = await fetchSentryStatus("https://example.com");
22+
23+
expect(status.indicator).toBe("none");
24+
expect(status.url).toBe("https://example.com");
25+
26+
const [calledUrl, calledInit] = customFetchMock.mock.calls[0] ?? [];
27+
expect(calledUrl).toBe("https://example.com/_health/");
28+
expect(calledInit).toHaveProperty("signal");
29+
});
30+
31+
test("self-hosted URL reports major on non-2xx", async () => {
32+
customFetchMock.mockResolvedValue(
33+
new Response("", { status: 503, statusText: "Service Unavailable" })
34+
);
35+
36+
const status = await fetchSentryStatus("https://self.sentry.local");
37+
38+
expect(status.indicator).toBe("major");
39+
expect(status.description).toContain("Service Unavailable");
40+
});

0 commit comments

Comments
 (0)