From 359222f417501a5c79b924be5ab71fe7ee93531e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:27:30 +0000 Subject: [PATCH] ref(mcp): Call shared uptime monitor endpoints Moves the uptime monitor endpoints (list, details, checks, create, update, delete) from SentryApiService into toolkit-core's shared API client, as is. SentryApiService keeps the project slug to ID lookup for the list filter and calls the wrappers with its Zod schemas. Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com> --- packages/mcp-core/src/api-client/client.ts | 300 ++++-------------- packages/toolkit-core/README.md | 3 + .../endpoints/uptime-monitors.test.ts | 159 ++++++++++ .../api-client/endpoints/uptime-monitors.ts | 218 +++++++++++++ 4 files changed, 433 insertions(+), 247 deletions(-) create mode 100644 packages/toolkit-core/src/api-client/endpoints/uptime-monitors.test.ts create mode 100644 packages/toolkit-core/src/api-client/endpoints/uptime-monitors.ts diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index a9e4484d7..7631f8a9b 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -49,6 +49,15 @@ import { listMonitorCheckIns, listMonitors, } from "@sentry/toolkit-core/api-client/endpoints/monitors"; +import { + createUptimeMonitor, + deleteUptimeMonitor, + getUptimeMonitor, + listUptimeMonitorChecks, + listUptimeMonitors, + updateUptimeMonitor, + type UptimeMonitorFields, +} from "@sentry/toolkit-core/api-client/endpoints/uptime-monitors"; import { getEventCommitters, getRelease, @@ -76,7 +85,6 @@ import { import { applyStatsMixinTimeParams, applyTimeParams, - normalizeStatsPeriod, parseStatsPeriod, } from "@sentry/toolkit-core/api-client/time-params"; import { createApiTransport } from "@sentry/toolkit-core/api-client/transport"; @@ -2693,10 +2701,8 @@ export class SentryApiService { } /** - * List uptime monitors for an organization. - * - * GET /organizations/{org}/uptime/ - * Source: src/sentry/uptime/endpoints/organiation_uptime_alert_index.py + * List uptime monitors for an organization. The endpoint filters by + * numeric project ID, so a project slug is looked up first. */ async listUptimeMonitors( { @@ -2716,82 +2722,41 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (projectSlug) { - // OrganizationEndpoint filter params accept project IDs via `project`. - const project = await this.getProject( - { - organizationSlug, - projectSlugOrId: projectSlug, - }, - opts, - ); - searchQuery.append("project", String(project.id)); - } - if (environment) { - searchQuery.append("environment", environment); - } - if (owner) { - searchQuery.append("owner", owner); - } - if (query) { - searchQuery.set("query", query); - } - if (limit !== undefined) { - searchQuery.set("per_page", String(limit)); - } - - const path = apiPath`/organizations/${organizationSlug}/uptime/`; - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, + const projectId = projectSlug + ? String( + ( + await this.getProject( + { organizationSlug, projectSlugOrId: projectSlug }, + opts, + ) + ).id, + ) + : undefined; + return await listUptimeMonitors( + this.target(opts?.host), + { organizationSlug, projectId, environment, owner, query, limit }, + UptimeMonitorListSchema, ); - return UptimeMonitorListSchema.parse(body); } - /** - * Get a single uptime monitor. - * - * GET /projects/{org}/{project}/uptime/{id}/ - * Source: src/sentry/uptime/endpoints/project_uptime_alert_details.py - */ async getUptimeMonitorDetails( - { - organizationSlug, - projectSlug, - uptimeMonitorId, - }: { + params: { organizationSlug: string; projectSlug: string; uptimeMonitorId: string; }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/${uptimeMonitorId}/`, - undefined, - opts, + return await getUptimeMonitor( + this.target(opts?.host), + params, + UptimeMonitorSchema, ); - return UptimeMonitorSchema.parse(body); } - /** - * List recent uptime checks for a monitor. - * - * GET /projects/{org}/{project}/uptime/{id}/checks/ - * Source: src/sentry/uptime/endpoints/project_uptime_alert_checks_index.py - */ + /** List a monitor's checks in a time range, 24 hours by default. */ async listUptimeMonitorChecks( - { - organizationSlug, - projectSlug, - uptimeMonitorId, - statsPeriod, - start, - end, - limit, - }: { + params: { organizationSlug: string; projectSlug: string; uptimeMonitorId: string; @@ -2802,219 +2767,60 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (limit !== undefined) { - searchQuery.set("per_page", String(limit)); - } - const normalizedStatsPeriod = normalizeStatsPeriod(statsPeriod); - const effectiveStatsPeriod = - start || end ? normalizedStatsPeriod : (normalizedStatsPeriod ?? "24h"); - if (effectiveStatsPeriod) { - parseStatsPeriod(effectiveStatsPeriod); - } - applyTimeParams(searchQuery, effectiveStatsPeriod, start, end); - - const path = apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/${uptimeMonitorId}/checks/`; - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, + return await listUptimeMonitorChecks( + this.target(opts?.host), + params, + UptimeCheckListSchema, ); - return UptimeCheckListSchema.parse(body); } /** - * Create an uptime monitor. - * - * POST /projects/{org}/{project}/uptime/ - * Source: src/sentry/uptime/endpoints/project_uptime_alert_index.py - * Body fields verified from UptimeMonitorValidator (camelCase via CamelSnakeSerializer). + * Body fields verified from UptimeMonitorValidator (camelCase via + * CamelSnakeSerializer). */ async createUptimeMonitor( - { - organizationSlug, - projectSlug, - name, - url, - intervalSeconds, - timeoutMs, - method, - headers, - body, - assertion, - status, - owner, - environment, - traceSampling, - responseCaptureEnabled, - recoveryThreshold, - downtimeThreshold, - }: { + params: { organizationSlug: string; projectSlug: string; name: string; url: string; intervalSeconds: number; timeoutMs: number; - method?: string; - headers?: Array<[string, string]>; - body?: string | null; - assertion?: unknown; - status?: "active" | "disabled"; - owner?: string | null; - environment?: string | null; - traceSampling?: boolean; - responseCaptureEnabled?: boolean; - recoveryThreshold?: number; - downtimeThreshold?: number; - }, + } & UptimeMonitorFields, opts?: RequestOptions, ): Promise { - const payload: Record = { - name, - url, - intervalSeconds, - timeoutMs, - }; - if (method !== undefined) payload.method = method; - if (headers !== undefined) payload.headers = headers; - if (body !== undefined) payload.body = body; - if (assertion !== undefined) payload.assertion = assertion; - if (status !== undefined) payload.status = status; - if (owner !== undefined) payload.owner = owner; - if (environment !== undefined) payload.environment = environment; - if (traceSampling !== undefined) payload.traceSampling = traceSampling; - if (responseCaptureEnabled !== undefined) { - payload.responseCaptureEnabled = responseCaptureEnabled; - } - if (recoveryThreshold !== undefined) { - payload.recoveryThreshold = recoveryThreshold; - } - if (downtimeThreshold !== undefined) { - payload.downtimeThreshold = downtimeThreshold; - } - - const responseBody = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/`, - { - method: "POST", - body: JSON.stringify(payload), - }, - opts, + return await createUptimeMonitor( + this.target(opts?.host), + params, + UptimeMonitorSchema, ); - return UptimeMonitorSchema.parse(responseBody); } - /** - * Update an uptime monitor. - * - * PUT /projects/{org}/{project}/uptime/{id}/ - * Source: src/sentry/uptime/endpoints/project_uptime_alert_details.py - */ async updateUptimeMonitor( - { - organizationSlug, - projectSlug, - uptimeMonitorId, - name, - url, - intervalSeconds, - timeoutMs, - method, - headers, - body, - assertion, - status, - owner, - environment, - traceSampling, - responseCaptureEnabled, - recoveryThreshold, - downtimeThreshold, - }: { + params: { organizationSlug: string; projectSlug: string; uptimeMonitorId: string; - name?: string; - url?: string; - intervalSeconds?: number; - timeoutMs?: number; - method?: string; - headers?: Array<[string, string]>; - body?: string | null; - assertion?: unknown; - status?: "active" | "disabled"; - owner?: string | null; - environment?: string | null; - traceSampling?: boolean; - responseCaptureEnabled?: boolean; - recoveryThreshold?: number; - downtimeThreshold?: number; - }, + } & UptimeMonitorFields, opts?: RequestOptions, ): Promise { - const payload: Record = {}; - if (name !== undefined) payload.name = name; - if (url !== undefined) payload.url = url; - if (intervalSeconds !== undefined) - payload.intervalSeconds = intervalSeconds; - if (timeoutMs !== undefined) payload.timeoutMs = timeoutMs; - if (method !== undefined) payload.method = method; - if (headers !== undefined) payload.headers = headers; - if (body !== undefined) payload.body = body; - if (assertion !== undefined) payload.assertion = assertion; - if (status !== undefined) payload.status = status; - if (owner !== undefined) payload.owner = owner; - if (environment !== undefined) payload.environment = environment; - if (traceSampling !== undefined) payload.traceSampling = traceSampling; - if (responseCaptureEnabled !== undefined) { - payload.responseCaptureEnabled = responseCaptureEnabled; - } - if (recoveryThreshold !== undefined) { - payload.recoveryThreshold = recoveryThreshold; - } - if (downtimeThreshold !== undefined) { - payload.downtimeThreshold = downtimeThreshold; - } - - const responseBody = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/${uptimeMonitorId}/`, - { - method: "PUT", - body: JSON.stringify(payload), - }, - opts, + return await updateUptimeMonitor( + this.target(opts?.host), + params, + UptimeMonitorSchema, ); - return UptimeMonitorSchema.parse(responseBody); } - /** - * Delete an uptime monitor. - * - * DELETE /projects/{org}/{project}/uptime/{id}/ - * Source: src/sentry/uptime/endpoints/project_uptime_alert_details.py - * Returns 202 with empty body. - */ + /** Returns 202 with no body; a 404 counts as already deleted. */ async deleteUptimeMonitor( - { - organizationSlug, - projectSlug, - uptimeMonitorId, - }: { + params: { organizationSlug: string; projectSlug: string; uptimeMonitorId: string; }, opts?: RequestOptions, ): Promise { - // Treat 404 as success so repeated deletes are idempotent. - await this.request( - apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/${uptimeMonitorId}/`, - { - method: "DELETE", - }, - { ...opts, allowStatuses: [404] }, - ); + await deleteUptimeMonitor(this.target(opts?.host), params); } /** diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 8671ca1b3..b78a2109e 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -69,6 +69,9 @@ the one exception. `listReleaseDeploys`, `listReleaseCommits`, and `getEventCommitters`. - `endpoints/monitors`: cron monitors (`listMonitors`, `getMonitor`, `deleteMonitorEnvironment`, `listMonitorCheckIns`, `getMonitorStats`). + - `endpoints/uptime-monitors`: `listUptimeMonitors`, `getUptimeMonitor`, + `listUptimeMonitorChecks`, `createUptimeMonitor`, `updateUptimeMonitor`, + and `deleteUptimeMonitor`. - `time-params` applies a time range to a query: `statsPeriod` or a `start`/`end` pair, or the `since`/`until` seconds `StatsMixin` endpoints read. It rejects a range that sets both or only one end. diff --git a/packages/toolkit-core/src/api-client/endpoints/uptime-monitors.test.ts b/packages/toolkit-core/src/api-client/endpoints/uptime-monitors.test.ts new file mode 100644 index 000000000..1371ad7f0 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/uptime-monitors.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../../test-utils/api-target.js"; +import { + createUptimeMonitor, + deleteUptimeMonitor, + listUptimeMonitorChecks, + listUptimeMonitors, + updateUptimeMonitor, +} from "./uptime-monitors.js"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; +const monitor = { + organizationSlug: "acme", + projectSlug: "web", + uptimeMonitorId: "42", +}; + +/** The URL and init of the request a wrapper call sent. */ +async function sentRequest( + call: (target: ReturnType["apiTarget"]) => unknown, +) { + const { apiTarget, request } = scriptedTarget(jsonResponse({})); + await call(apiTarget); + const [url, init] = request.mock.calls[0] ?? []; + return { url, init }; +} + +describe("listUptimeMonitors", () => { + it("sends the filters", async () => { + const { url } = await sentRequest((target) => + listUptimeMonitors( + target, + { + organizationSlug: "acme", + projectId: "7", + environment: "prod", + owner: "team:1", + query: "api", + limit: 5, + }, + AnyJson, + ), + ); + + expect(url).toBe( + `${API}/organizations/acme/uptime/?project=7&environment=prod&owner=team%3A1&query=api&per_page=5`, + ); + }); + + it("sends no query string without filters", async () => { + const { url } = await sentRequest((target) => + listUptimeMonitors(target, { organizationSlug: "acme" }, AnyJson), + ); + + expect(url).toBe(`${API}/organizations/acme/uptime/`); + }); +}); + +describe("listUptimeMonitorChecks", () => { + it.each([ + [{}, "statsPeriod=24h"], + [{ statsPeriod: " 7d ", limit: 3 }, "per_page=3&statsPeriod=7d"], + [ + { start: "2026-01-01", end: "2026-01-02" }, + "start=2026-01-01&end=2026-01-02", + ], + ])("sends %o as %s", async (params, query) => { + const { url } = await sentRequest((target) => + listUptimeMonitorChecks(target, { ...monitor, ...params }, AnyJson), + ); + + expect(url).toBe(`${API}/projects/acme/web/uptime/42/checks/?${query}`); + }); + + it("rejects a malformed period before sending", async () => { + await expect( + sentRequest((target) => + listUptimeMonitorChecks( + target, + { ...monitor, statsPeriod: "1y" }, + AnyJson, + ), + ), + ).rejects.toThrow("statsPeriod"); + }); +}); + +describe("createUptimeMonitor", () => { + it("sends the required fields first and every set field, null included", async () => { + const { url, init } = await sentRequest((target) => + createUptimeMonitor( + target, + { + organizationSlug: "acme", + projectSlug: "web", + environment: null, + method: "GET", + name: "API", + url: "https://example.com", + intervalSeconds: 60, + timeoutMs: 1000, + }, + AnyJson, + ), + ); + + expect(url).toBe(`${API}/projects/acme/web/uptime/`); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe( + JSON.stringify({ + name: "API", + url: "https://example.com", + intervalSeconds: 60, + timeoutMs: 1000, + method: "GET", + environment: null, + }), + ); + }); +}); + +describe("updateUptimeMonitor", () => { + it("sends only the fields that are set", async () => { + const { url, init } = await sentRequest((target) => + updateUptimeMonitor( + target, + { ...monitor, status: "disabled", owner: null }, + AnyJson, + ), + ); + + expect(url).toBe(`${API}/projects/acme/web/uptime/42/`); + expect(init?.method).toBe("PUT"); + expect(init?.body).toBe( + JSON.stringify({ status: "disabled", owner: null }), + ); + }); +}); + +describe("deleteUptimeMonitor", () => { + it("treats a 404 as already deleted", async () => { + const { apiTarget, request } = scriptedTarget( + new Response(null, { status: 404 }), + ); + + await deleteUptimeMonitor(apiTarget, monitor); + + expect(request.mock.calls[0]?.[0]).toBe( + `${API}/projects/acme/web/uptime/42/`, + ); + expect(request.mock.calls[0]?.[1]?.method).toBe("DELETE"); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/uptime-monitors.ts b/packages/toolkit-core/src/api-client/endpoints/uptime-monitors.ts new file mode 100644 index 000000000..b479fc875 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/uptime-monitors.ts @@ -0,0 +1,218 @@ +/** + * Uptime monitor endpoints. Each wrapper validates the response with the + * schema the host passes, so the host's schema decides the result type. + */ + +import { apiPath, withQuery } from "../api-path.js"; +import type { ApiTarget } from "../core.js"; +import { + applyTimeParams, + defaultStatsPeriod, + parseStatsPeriod, +} from "../time-params.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +export type ListUptimeMonitorsParams = { + organizationSlug: string; + /** Numeric project ID; the endpoint filters by ID, not slug. */ + projectId?: string; + environment?: string; + owner?: string; + query?: string; + /** Page size. */ + limit?: number; +}; + +/** The first page of the organization's uptime monitors. */ +export async function listUptimeMonitors( + { core, origin }: ApiTarget, + { + organizationSlug, + projectId, + environment, + owner, + query, + limit, + }: ListUptimeMonitorsParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (projectId) { + params.append("project", projectId); + } + if (environment) { + params.append("environment", environment); + } + if (owner) { + params.append("owner", owner); + } + if (query) { + params.set("query", query); + } + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + const body = await core.requestJson( + origin, + withQuery(apiPath`/organizations/${organizationSlug}/uptime/`, params), + ); + return validateResponse(schema, body); +} + +export type UptimeMonitorParams = { + organizationSlug: string; + projectSlug: string; + uptimeMonitorId: string; +}; + +function uptimeMonitorPath({ + organizationSlug, + projectSlug, + uptimeMonitorId, +}: UptimeMonitorParams): string { + return apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/${uptimeMonitorId}/`; +} + +/** One uptime monitor. */ +export async function getUptimeMonitor( + { core, origin }: ApiTarget, + params: UptimeMonitorParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson(origin, uptimeMonitorPath(params)); + return validateResponse(schema, body); +} + +/** The first page of a monitor's checks in a time range, 24 hours by default. */ +export async function listUptimeMonitorChecks( + { core, origin }: ApiTarget, + params: UptimeMonitorParams & { + statsPeriod?: string; + start?: string; + end?: string; + limit?: number; + }, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + if (params.limit !== undefined) { + query.set("per_page", String(params.limit)); + } + const statsPeriod = defaultStatsPeriod(params); + if (statsPeriod) { + parseStatsPeriod(statsPeriod); + } + applyTimeParams(query, statsPeriod, params.start, params.end); + const body = await core.requestJson( + origin, + withQuery(`${uptimeMonitorPath(params)}checks/`, query), + ); + return validateResponse(schema, body); +} + +/** The writable fields of an uptime monitor, in the order they are sent. */ +export type UptimeMonitorFields = { + name?: string; + url?: string; + intervalSeconds?: number; + timeoutMs?: number; + method?: string; + headers?: Array<[string, string]>; + body?: string | null; + assertion?: unknown; + status?: "active" | "disabled"; + owner?: string | null; + environment?: string | null; + traceSampling?: boolean; + responseCaptureEnabled?: boolean; + recoveryThreshold?: number; + downtimeThreshold?: number; +}; + +const UPTIME_MONITOR_FIELDS = [ + "name", + "url", + "intervalSeconds", + "timeoutMs", + "method", + "headers", + "body", + "assertion", + "status", + "owner", + "environment", + "traceSampling", + "responseCaptureEnabled", + "recoveryThreshold", + "downtimeThreshold", +] as const satisfies readonly (keyof UptimeMonitorFields)[]; + +/** The request body: every field that is set, `null` included. */ +function uptimeMonitorBody(fields: UptimeMonitorFields): string { + const payload: Record = {}; + for (const field of UPTIME_MONITOR_FIELDS) { + if (fields[field] !== undefined) { + payload[field] = fields[field]; + } + } + return JSON.stringify(payload); +} + +/** Create an uptime monitor in a project. */ +export async function createUptimeMonitor( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + ...fields + }: { organizationSlug: string; projectSlug: string } & UptimeMonitorFields & + Required< + Pick< + UptimeMonitorFields, + "name" | "url" | "intervalSeconds" | "timeoutMs" + > + >, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/`, + { method: "POST", body: uptimeMonitorBody(fields) }, + ); + return validateResponse(schema, body); +} + +/** Update an uptime monitor. Only the fields that are set are sent. */ +export async function updateUptimeMonitor( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + uptimeMonitorId, + ...fields + }: UptimeMonitorParams & UptimeMonitorFields, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + uptimeMonitorPath({ organizationSlug, projectSlug, uptimeMonitorId }), + { method: "PUT", body: uptimeMonitorBody(fields) }, + ); + return validateResponse(schema, body); +} + +/** + * Delete an uptime monitor. Sentry answers 202 with no body; a 404 (already + * gone) is not an error, so repeated deletes succeed. + */ +export async function deleteUptimeMonitor( + { core, origin }: ApiTarget, + params: UptimeMonitorParams, +): Promise { + await core.request( + origin, + uptimeMonitorPath(params), + { method: "DELETE" }, + { allowStatuses: [404] }, + ); +}