From 53800c9729443b7ca9915d2537b4aa679c67a739 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:20:51 +0000 Subject: [PATCH] ref(mcp): Call shared cron monitor endpoints and time range helpers Moves the cron monitor endpoints and SentryApiService's time range helpers (statsPeriod, start/end, and StatsMixin since/until) into toolkit-core's shared API client, as is. SentryApiService calls them. Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com> --- packages/mcp-core/src/api-client/client.ts | 298 ++++-------------- packages/toolkit-core/README.md | 5 + .../src/api-client/endpoints/monitors.test.ts | 133 ++++++++ .../src/api-client/endpoints/monitors.ts | 174 ++++++++++ .../src/api-client/time-params.test.ts | 100 ++++++ .../src/api-client/time-params.ts | 161 ++++++++++ 6 files changed, 637 insertions(+), 234 deletions(-) create mode 100644 packages/toolkit-core/src/api-client/endpoints/monitors.test.ts create mode 100644 packages/toolkit-core/src/api-client/endpoints/monitors.ts create mode 100644 packages/toolkit-core/src/api-client/time-params.test.ts create mode 100644 packages/toolkit-core/src/api-client/time-params.ts diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index c499e3404..a9e4484d7 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -42,6 +42,13 @@ import { getDashboard, listDashboards, } from "@sentry/toolkit-core/api-client/endpoints/dashboards"; +import { + deleteMonitorEnvironment, + getMonitor, + getMonitorStats, + listMonitorCheckIns, + listMonitors, +} from "@sentry/toolkit-core/api-client/endpoints/monitors"; import { getEventCommitters, getRelease, @@ -66,6 +73,12 @@ import { createTeam, listTeams, } from "@sentry/toolkit-core/api-client/endpoints/teams"; +import { + applyStatsMixinTimeParams, + applyTimeParams, + normalizeStatsPeriod, + parseStatsPeriod, +} from "@sentry/toolkit-core/api-client/time-params"; import { createApiTransport } from "@sentry/toolkit-core/api-client/transport"; import { z } from "zod"; import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants"; @@ -293,32 +306,11 @@ type ExplorerUrlParams = ExplorerAggregateParams & { end?: string; }; -function normalizeStatsPeriod(statsPeriod?: string): string | undefined { - const normalized = statsPeriod?.trim(); - return normalized || undefined; -} - function formatWorkflowNameQuery(query: string): string { const escapedQuery = query.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return `name:"*${escapedQuery}*"`; } -function parseStatsPeriod(statsPeriod: string): { - amount: number; - unit: string; -} { - const match = /^(\d+)([smhdw])$/.exec(statsPeriod); - if (!match) { - throw new ApiValidationError( - "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", - ); - } - return { - amount: Number(match[1]), - unit: match[2], - }; -} - function getNextCursor(linkHeader: string | null): string | null { return parseSentryLinkHeader(linkHeader).nextCursor ?? null; } @@ -695,109 +687,6 @@ export class SentryApiService { return isPublicSentryHost(this.host); } - /** - * Validates and applies time parameters to a URLSearchParams object. - * - * Enforces mutual exclusivity between relative (statsPeriod) and - * absolute (start/end) time ranges, and ensures start/end are paired. - * - * @param queryParams The URLSearchParams to modify - * @param statsPeriod Relative time period (e.g., "24h", "7d") - * @param start Absolute start time (ISO 8601) - * @param end Absolute end time (ISO 8601) - * @throws {ApiValidationError} If time parameters are invalid - */ - private applyTimeParams( - queryParams: URLSearchParams, - statsPeriod?: string, - start?: string, - end?: string, - ): void { - if (statsPeriod && (start || end)) { - throw new ApiValidationError( - "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.", - ); - } - if ((start && !end) || (!start && end)) { - throw new ApiValidationError( - "Both start and end parameters must be provided together for absolute time ranges.", - ); - } - if (statsPeriod) { - queryParams.set("statsPeriod", statsPeriod); - } else if (start && end) { - queryParams.set("start", start); - queryParams.set("end", end); - } - } - - private applyStatsMixinTimeParams( - queryParams: URLSearchParams, - statsPeriod?: string, - start?: string, - end?: string, - resolutionSeconds?: number, - ): void { - if (statsPeriod && (start || end)) { - throw new ApiValidationError( - "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.", - ); - } - if ((start && !end) || (!start && end)) { - throw new ApiValidationError( - "Both start and end parameters must be provided together for absolute time ranges.", - ); - } - - let since: number; - let until: number; - - if (start && end) { - const startMs = Date.parse(start); - const endMs = Date.parse(end); - if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { - throw new ApiValidationError( - "start and end parameters must be valid ISO 8601 date strings.", - ); - } - since = Math.floor(startMs / 1000); - until = Math.floor(endMs / 1000); - } else { - const period = statsPeriod ?? "24h"; - const { amount, unit } = parseStatsPeriod(period); - let unitSeconds: number; - switch (unit) { - case "s": - unitSeconds = 1; - break; - case "m": - unitSeconds = 60; - break; - case "h": - unitSeconds = 60 * 60; - break; - case "d": - unitSeconds = 60 * 60 * 24; - break; - case "w": - unitSeconds = 60 * 60 * 24 * 7; - break; - default: - throw new ApiValidationError( - "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", - ); - } - until = Math.floor(Date.now() / 1000); - since = until - amount * unitSeconds; - } - - queryParams.set("since", String(since)); - queryParams.set("until", String(until)); - if (resolutionSeconds !== undefined) { - queryParams.set("resolution", `${resolutionSeconds}s`); - } - } - /** Headers for every request after `Content-Type`. */ private requestHeaders(): Record { const headers: Record = { "User-Agent": USER_AGENT }; @@ -2673,32 +2562,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (projectSlug) { - searchQuery.append("projectSlug", projectSlug); - } - 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 body = await this.requestJSON( - searchQuery.toString() - ? apiPath`/organizations/${organizationSlug}/monitors/` + - `?${searchQuery.toString()}` - : apiPath`/organizations/${organizationSlug}/monitors/`, - undefined, - opts, + return await listMonitors( + this.target(opts?.host), + { organizationSlug, projectSlug, environment, owner, query, limit }, + MonitorListSchema, ); - return MonitorListSchema.parse(body); } async getMonitorDetails( @@ -2715,20 +2583,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (environment) { - searchQuery.append("environment", environment); - } - - const path = projectSlug - ? apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/` - : apiPath`/organizations/${organizationSlug}/monitors/${monitorSlug}/`; - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, + return await getMonitor( + this.target(opts?.host), + { organizationSlug, projectSlug, monitorSlug, environment }, + MonitorSchema, ); - return MonitorSchema.parse(body); } /** @@ -2749,11 +2608,12 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - await this.request( - apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/environments/${environment}/`, - { method: "DELETE" }, - { ...opts, allowStatuses: [404] }, - ); + await deleteMonitorEnvironment(this.target(opts?.host), { + organizationSlug, + projectSlug, + monitorSlug, + environment, + }); } async listMonitorCheckIns( @@ -2778,30 +2638,20 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (environment) { - searchQuery.append("environment", environment); - } - if (limit !== undefined) { - searchQuery.set("per_page", String(limit)); - } - const normalizedStatsPeriod = normalizeStatsPeriod(statsPeriod); - const effectiveStatsPeriod = - start || end ? normalizedStatsPeriod : (normalizedStatsPeriod ?? "24h"); - if (effectiveStatsPeriod) { - parseStatsPeriod(effectiveStatsPeriod); - } - this.applyTimeParams(searchQuery, effectiveStatsPeriod, start, end); - - const path = projectSlug - ? apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/checkins/` - : apiPath`/organizations/${organizationSlug}/monitors/${monitorSlug}/checkins/`; - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, + return await listMonitorCheckIns( + this.target(opts?.host), + { + organizationSlug, + projectSlug, + monitorSlug, + environment, + statsPeriod, + start, + end, + limit, + }, + MonitorCheckInListSchema, ); - return MonitorCheckInListSchema.parse(body); } async getMonitorStats( @@ -2826,30 +2676,20 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (environment) { - searchQuery.append("environment", environment); - } - const normalizedStatsPeriod = normalizeStatsPeriod(statsPeriod); - const effectiveStatsPeriod = - start || end ? normalizedStatsPeriod : (normalizedStatsPeriod ?? "24h"); - this.applyStatsMixinTimeParams( - searchQuery, - effectiveStatsPeriod, - start, - end, - rollup, - ); - - const path = projectSlug - ? apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/stats/` - : apiPath`/organizations/${organizationSlug}/monitors/${monitorSlug}/stats/`; - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, + return await getMonitorStats( + this.target(opts?.host), + { + organizationSlug, + projectSlug, + monitorSlug, + environment, + statsPeriod, + start, + end, + rollup, + }, + MonitorStatsSchema, ); - return MonitorStatsSchema.parse(body); } /** @@ -2972,7 +2812,7 @@ export class SentryApiService { if (effectiveStatsPeriod) { parseStatsPeriod(effectiveStatsPeriod); } - this.applyTimeParams(searchQuery, effectiveStatsPeriod, start, end); + applyTimeParams(searchQuery, effectiveStatsPeriod, start, end); const path = apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/${uptimeMonitorId}/checks/`; const body = await this.requestJSON( @@ -3233,7 +3073,7 @@ export class SentryApiService { if (project) { searchQuery.set("project", project); } - this.applyTimeParams(searchQuery, statsPeriod, start, end); + applyTimeParams(searchQuery, statsPeriod, start, end); if (useCache !== undefined) { searchQuery.set("useCache", useCache ? "1" : "0"); } @@ -3305,7 +3145,7 @@ export class SentryApiService { searchQuery.append("field", field); } } - this.applyTimeParams(searchQuery, statsPeriod, start, end); + applyTimeParams(searchQuery, statsPeriod, start, end); const body = await this.requestJSON( searchQuery.toString() @@ -3423,7 +3263,7 @@ export class SentryApiService { queryParams.append("environment", value); } } - this.applyTimeParams(queryParams, statsPeriod, start, end); + applyTimeParams(queryParams, statsPeriod, start, end); for (const field of fields ?? []) { queryParams.append("field", field); } @@ -3523,7 +3363,7 @@ export class SentryApiService { if (context) { queryParams.set("expand", "context"); } - this.applyTimeParams(queryParams, statsPeriod, start, end); + applyTimeParams(queryParams, statsPeriod, start, end); const url = apiPath`/organizations/${organizationSlug}/trace-items/attributes/` + @@ -4750,12 +4590,7 @@ export class SentryApiService { queryParams.set("query", params.query); queryParams.set("dataset", params.dataset ?? "errors"); - this.applyTimeParams( - queryParams, - params.statsPeriod, - params.start, - params.end, - ); + applyTimeParams(queryParams, params.statsPeriod, params.start, params.end); if (params.projectId) { queryParams.set("project", params.projectId); @@ -4797,12 +4632,7 @@ export class SentryApiService { queryParams.set("query", params.query); queryParams.set("dataset", params.dataset); - this.applyTimeParams( - queryParams, - params.statsPeriod, - params.start, - params.end, - ); + applyTimeParams(queryParams, params.statsPeriod, params.start, params.end); if (params.projectId) { queryParams.set("project", params.projectId); @@ -4949,7 +4779,7 @@ export class SentryApiService { if (interval) { queryParams.set("interval", interval); } - this.applyTimeParams(queryParams, statsPeriod, start, end); + applyTimeParams(queryParams, statsPeriod, start, end); if (projectId) { queryParams.set("project", projectId); } @@ -4991,7 +4821,7 @@ export class SentryApiService { if (interval) { queryParams.set("interval", interval); } - this.applyTimeParams(queryParams, statsPeriod, start, end); + applyTimeParams(queryParams, statsPeriod, start, end); if (projectId) { queryParams.set("project", projectId); } @@ -5242,7 +5072,7 @@ export class SentryApiService { for (let page = 0; page < maxPages; page++) { const queryParams = new URLSearchParams(); queryParams.set("per_page", String(perPage)); - this.applyTimeParams( + applyTimeParams( queryParams, start || end ? undefined : statsPeriod, start, @@ -5330,7 +5160,7 @@ export class SentryApiService { queryParams.append("environment", environmentName); } } - this.applyTimeParams(queryParams, statsPeriod, start, end); + applyTimeParams(queryParams, statsPeriod, start, end); if (limit !== undefined) { queryParams.set("per_page", String(limit)); } diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 09be4f8ce..8671ca1b3 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -67,6 +67,11 @@ the one exception. - `endpoints/dashboards`: `listDashboards` and `getDashboard`. - `endpoints/releases`: `listReleases`, `getRelease`, `listReleaseDeploys`, `listReleaseCommits`, and `getEventCommitters`. + - `endpoints/monitors`: cron monitors (`listMonitors`, `getMonitor`, + `deleteMonitorEnvironment`, `listMonitorCheckIns`, `getMonitorStats`). +- `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. - `pagination`, `retry`, `transport`, and `response-cache` are described in the sections below. The `node:zlib` body codec stays outside, at `response-cache/codec`, since browsers lack it. diff --git a/packages/toolkit-core/src/api-client/endpoints/monitors.test.ts b/packages/toolkit-core/src/api-client/endpoints/monitors.test.ts new file mode 100644 index 000000000..5cd8c95e9 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/monitors.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../../test-utils/api-target.js"; +import { + deleteMonitorEnvironment, + getMonitor, + getMonitorStats, + listMonitorCheckIns, + listMonitors, +} from "./monitors.js"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; +const monitor = { organizationSlug: "acme", monitorSlug: "nightly" }; + +/** The URL a wrapper call requested. */ +async function requestedUrl( + call: (target: ReturnType["apiTarget"]) => unknown, +) { + const { apiTarget, request } = scriptedTarget(jsonResponse([])); + await call(apiTarget); + return request.mock.calls[0]?.[0]; +} + +describe("listMonitors", () => { + it("sends the filters", async () => { + expect( + await requestedUrl((target) => + listMonitors( + target, + { + organizationSlug: "acme", + projectSlug: "web", + environment: "prod", + owner: "team:1", + query: "job", + limit: 5, + }, + AnyJson, + ), + ), + ).toBe( + `${API}/organizations/acme/monitors/?projectSlug=web&environment=prod&owner=team%3A1&query=job&per_page=5`, + ); + }); +}); + +describe("getMonitor", () => { + it.each([ + [{}, `${API}/organizations/acme/monitors/nightly/`], + [ + { projectSlug: "web", environment: "prod" }, + `${API}/projects/acme/web/monitors/nightly/?environment=prod`, + ], + ])("sends %o to %s", async (params, url) => { + expect( + await requestedUrl((target) => + getMonitor(target, { ...monitor, ...params }, AnyJson), + ), + ).toBe(url); + }); +}); + +describe("deleteMonitorEnvironment", () => { + it("treats a 404 as already deleted", async () => { + const { apiTarget, request } = scriptedTarget( + new Response(null, { status: 404 }), + ); + + await deleteMonitorEnvironment(apiTarget, { + ...monitor, + projectSlug: "web", + environment: "prod", + }); + + expect(request.mock.calls[0]?.[0]).toBe( + `${API}/projects/acme/web/monitors/nightly/environments/prod/`, + ); + expect(request.mock.calls[0]?.[1]?.method).toBe("DELETE"); + }); +}); + +describe("listMonitorCheckIns", () => { + 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) => { + expect( + await requestedUrl((target) => + listMonitorCheckIns(target, { ...monitor, ...params }, AnyJson), + ), + ).toBe(`${API}/organizations/acme/monitors/nightly/checkins/?${query}`); + }); + + it("rejects a malformed period before sending", async () => { + await expect( + requestedUrl((target) => + listMonitorCheckIns(target, { ...monitor, statsPeriod: "1y" }, AnyJson), + ), + ).rejects.toThrow("statsPeriod"); + }); +}); + +describe("getMonitorStats", () => { + it("sends the range as since and until seconds", async () => { + const url = await requestedUrl((target) => + getMonitorStats( + target, + { + ...monitor, + projectSlug: "web", + environment: "prod", + start: "1970-01-01T00:00:10Z", + end: "1970-01-01T00:00:20Z", + rollup: 60, + }, + AnyJson, + ), + ); + + expect(url).toBe( + `${API}/projects/acme/web/monitors/nightly/stats/?environment=prod&since=10&until=20&resolution=60s`, + ); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/monitors.ts b/packages/toolkit-core/src/api-client/endpoints/monitors.ts new file mode 100644 index 000000000..45fe17934 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/monitors.ts @@ -0,0 +1,174 @@ +/** + * Cron monitor endpoints. With a project, reads go to the project's + * endpoints, otherwise to the organization's. 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 { + applyStatsMixinTimeParams, + applyTimeParams, + defaultStatsPeriod, + parseStatsPeriod, +} from "../time-params.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +export type ListMonitorsParams = { + organizationSlug: string; + projectSlug?: string; + environment?: string; + owner?: string; + query?: string; + /** Page size. */ + limit?: number; +}; + +/** The first page of the organization's cron monitors. */ +export async function listMonitors( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + environment, + owner, + query, + limit, + }: ListMonitorsParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (projectSlug) { + params.append("projectSlug", projectSlug); + } + 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}/monitors/`, params), + ); + return validateResponse(schema, body); +} + +export type MonitorParams = { + organizationSlug: string; + projectSlug?: string; + monitorSlug: string; + environment?: string; +}; + +function monitorPath( + { organizationSlug, projectSlug, monitorSlug }: MonitorParams, + suffix = "", +): string { + const path = projectSlug + ? apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/` + : apiPath`/organizations/${organizationSlug}/monitors/${monitorSlug}/`; + return `${path}${suffix}`; +} + +function environmentQuery(environment: string | undefined): URLSearchParams { + const params = new URLSearchParams(); + if (environment) { + params.append("environment", environment); + } + return params; +} + +/** One cron monitor. */ +export async function getMonitor( + { core, origin }: ApiTarget, + params: MonitorParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + withQuery(monitorPath(params), environmentQuery(params.environment)), + ); + return validateResponse(schema, body); +} + +/** + * Schedule deletion of one environment of a cron monitor. Sentry answers 202 + * with no body; a 404 (already gone) is not an error. + */ +export async function deleteMonitorEnvironment( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + monitorSlug, + environment, + }: MonitorParams & { projectSlug: string; environment: string }, +): Promise { + await core.request( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/environments/${environment}/`, + { method: "DELETE" }, + { allowStatuses: [404] }, + ); +} + +export type MonitorTimeRangeParams = MonitorParams & { + /** Relative range such as `24h`. Default: `24h` without `start` or `end`. */ + statsPeriod?: string; + start?: string; + end?: string; +}; + +/** The first page of a cron monitor's check-ins in a time range. */ +export async function listMonitorCheckIns( + { core, origin }: ApiTarget, + params: MonitorTimeRangeParams & { limit?: number }, + schema: ResponseSchema, +): Promise { + const query = environmentQuery(params.environment); + 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(monitorPath(params, "checkins/"), query), + ); + return validateResponse(schema, body); +} + +/** A cron monitor's check-in counts over a time range. */ +export async function getMonitorStats( + { core, origin }: ApiTarget, + params: MonitorTimeRangeParams & { + /** Bucket size in seconds. */ + rollup?: number; + }, + schema: ResponseSchema, +): Promise { + const query = environmentQuery(params.environment); + applyStatsMixinTimeParams( + query, + defaultStatsPeriod(params), + params.start, + params.end, + params.rollup, + ); + const body = await core.requestJson( + origin, + withQuery(monitorPath(params, "stats/"), query), + ); + return validateResponse(schema, body); +} diff --git a/packages/toolkit-core/src/api-client/time-params.test.ts b/packages/toolkit-core/src/api-client/time-params.test.ts new file mode 100644 index 000000000..b6d8dfad1 --- /dev/null +++ b/packages/toolkit-core/src/api-client/time-params.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiValidationError } from "./errors.js"; +import { + applyStatsMixinTimeParams, + applyTimeParams, + defaultStatsPeriod, + normalizeStatsPeriod, + parseStatsPeriod, +} from "./time-params.js"; + +function applied(apply: (query: URLSearchParams) => void): string { + const query = new URLSearchParams(); + apply(query); + return query.toString(); +} + +describe("normalizeStatsPeriod", () => { + it.each([ + [" 24h ", "24h"], + [" ", undefined], + [undefined, undefined], + ])("normalizes %j to %j", (input, output) => { + expect(normalizeStatsPeriod(input)).toBe(output); + }); +}); + +describe("defaultStatsPeriod", () => { + it.each([ + [{}, "24h"], + [{ statsPeriod: " 7d " }, "7d"], + [{ start: "2026-01-01", end: "2026-01-02" }, undefined], + ])("defaults %o to %j", (range, period) => { + expect(defaultStatsPeriod(range)).toBe(period); + }); +}); + +describe("parseStatsPeriod", () => { + it("splits the amount and unit", () => { + expect(parseStatsPeriod("14d")).toEqual({ amount: 14, unit: "d" }); + }); + + it("rejects other formats", () => { + expect(() => parseStatsPeriod("1 day")).toThrow(ApiValidationError); + }); +}); + +describe("applyTimeParams", () => { + it.each([ + [["24h"], "statsPeriod=24h"], + [ + [undefined, "2026-01-01", "2026-01-02"], + "start=2026-01-01&end=2026-01-02", + ], + [[], ""], + ] as const)("applies %j as %s", (args, query) => { + expect(applied((q) => applyTimeParams(q, ...args))).toBe(query); + }); + + it.each([ + ["24h", "2026-01-01", "2026-01-02"], + [undefined, "2026-01-01", undefined], + ])("rejects statsPeriod=%s start=%s end=%s", (statsPeriod, start, end) => { + expect(() => + applyTimeParams(new URLSearchParams(), statsPeriod, start, end), + ).toThrow(ApiValidationError); + }); +}); + +describe("applyStatsMixinTimeParams", () => { + afterEach(() => vi.useRealTimers()); + + it("turns a relative period into seconds before now", () => { + vi.useFakeTimers({ now: 10_000_000 }); + + expect( + applied((q) => + applyStatsMixinTimeParams(q, "1h", undefined, undefined, 60), + ), + ).toBe("since=6400&until=10000&resolution=60s"); + }); + + it("turns an absolute range into seconds", () => { + expect( + applied((q) => + applyStatsMixinTimeParams( + q, + undefined, + "1970-01-01T00:00:10Z", + "1970-01-01T00:00:20Z", + ), + ), + ).toBe("since=10&until=20"); + }); + + it("rejects an unparseable date", () => { + expect(() => + applyStatsMixinTimeParams(new URLSearchParams(), undefined, "x", "y"), + ).toThrow(ApiValidationError); + }); +}); diff --git a/packages/toolkit-core/src/api-client/time-params.ts b/packages/toolkit-core/src/api-client/time-params.ts new file mode 100644 index 000000000..0995b9797 --- /dev/null +++ b/packages/toolkit-core/src/api-client/time-params.ts @@ -0,0 +1,161 @@ +/** + * Time range query parameters shared by the endpoints that take one: + * `statsPeriod` or a `start`/`end` pair, or the `since`/`until` seconds of + * Sentry's `StatsMixin` endpoints. + */ + +import { ApiValidationError } from "./errors.js"; + +/** A trimmed `statsPeriod`, or undefined when it is blank. */ +export function normalizeStatsPeriod(statsPeriod?: string): string | undefined { + const normalized = statsPeriod?.trim(); + return normalized || undefined; +} + +/** + * The relative period to query: the trimmed `statsPeriod`, or 24 hours when + * neither it nor an absolute `start` or `end` is given. + */ +export function defaultStatsPeriod({ + statsPeriod, + start, + end, +}: { + statsPeriod?: string; + start?: string; + end?: string; +}): string | undefined { + const normalized = normalizeStatsPeriod(statsPeriod); + return start || end ? normalized : (normalized ?? "24h"); +} + +/** + * Split a relative period such as `24h` into its amount and unit. + * + * @throws {ApiValidationError} For any other format + */ +export function parseStatsPeriod(statsPeriod: string): { + amount: number; + unit: string; +} { + const match = /^(\d+)([smhdw])$/.exec(statsPeriod); + if (!match) { + throw new ApiValidationError( + "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", + ); + } + return { + amount: Number(match[1]), + unit: match[2], + }; +} + +/** + * Validates and applies time parameters to a URLSearchParams object. + * + * Enforces mutual exclusivity between relative (statsPeriod) and + * absolute (start/end) time ranges, and ensures start/end are paired. + * + * @param queryParams The URLSearchParams to modify + * @param statsPeriod Relative time period (e.g., "24h", "7d") + * @param start Absolute start time (ISO 8601) + * @param end Absolute end time (ISO 8601) + * @throws {ApiValidationError} If time parameters are invalid + */ +export function applyTimeParams( + queryParams: URLSearchParams, + statsPeriod?: string, + start?: string, + end?: string, +): void { + if (statsPeriod && (start || end)) { + throw new ApiValidationError( + "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.", + ); + } + if ((start && !end) || (!start && end)) { + throw new ApiValidationError( + "Both start and end parameters must be provided together for absolute time ranges.", + ); + } + if (statsPeriod) { + queryParams.set("statsPeriod", statsPeriod); + } else if (start && end) { + queryParams.set("start", start); + queryParams.set("end", end); + } +} + +/** + * Like {@link applyTimeParams}, but as the `since` and `until` Unix seconds + * that `StatsMixin` endpoints read, with an optional `resolution`. A missing + * range means the last 24 hours. + * + * @throws {ApiValidationError} If time parameters are invalid + */ +export function applyStatsMixinTimeParams( + queryParams: URLSearchParams, + statsPeriod?: string, + start?: string, + end?: string, + resolutionSeconds?: number, +): void { + if (statsPeriod && (start || end)) { + throw new ApiValidationError( + "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.", + ); + } + if ((start && !end) || (!start && end)) { + throw new ApiValidationError( + "Both start and end parameters must be provided together for absolute time ranges.", + ); + } + + let since: number; + let until: number; + + if (start && end) { + const startMs = Date.parse(start); + const endMs = Date.parse(end); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + throw new ApiValidationError( + "start and end parameters must be valid ISO 8601 date strings.", + ); + } + since = Math.floor(startMs / 1000); + until = Math.floor(endMs / 1000); + } else { + const period = statsPeriod ?? "24h"; + const { amount, unit } = parseStatsPeriod(period); + let unitSeconds: number; + switch (unit) { + case "s": + unitSeconds = 1; + break; + case "m": + unitSeconds = 60; + break; + case "h": + unitSeconds = 60 * 60; + break; + case "d": + unitSeconds = 60 * 60 * 24; + break; + case "w": + unitSeconds = 60 * 60 * 24 * 7; + break; + default: + throw new ApiValidationError( + "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", + ); + } + until = Math.floor(Date.now() / 1000); + since = until - amount * unitSeconds; + } + + queryParams.set("since", String(since)); + queryParams.set("until", String(until)); + if (resolutionSeconds !== undefined) { + queryParams.set("resolution", `${resolutionSeconds}s`); + } +}