From 2c5c7ec60c7e186a0162e2cedab953d6b0104e55 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:34:26 +0000 Subject: [PATCH] ref(mcp): Call shared replay endpoints Moves the replay search, replay details, issue replay IDs, and recording segment endpoints from SentryApiService into toolkit-core's shared API client, as is. SentryApiService unwraps the data envelopes and picks the issue's IDs. Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com> --- docs/contributing/api-patterns.md | 19 ++ packages/mcp-core/src/api-client/client.ts | 153 +++------------ packages/toolkit-core/README.md | 3 + .../src/api-client/endpoints/replays.test.ts | 106 +++++++++++ .../src/api-client/endpoints/replays.ts | 179 ++++++++++++++++++ 5 files changed, 338 insertions(+), 122 deletions(-) create mode 100644 packages/toolkit-core/src/api-client/endpoints/replays.test.ts create mode 100644 packages/toolkit-core/src/api-client/endpoints/replays.ts diff --git a/docs/contributing/api-patterns.md b/docs/contributing/api-patterns.md index 0c8ee19a7..e7c6f063a 100644 --- a/docs/contributing/api-patterns.md +++ b/docs/contributing/api-patterns.md @@ -47,6 +47,25 @@ See `packages/mcp-core/src/api-utils.ts` and [adding-tools.md](adding-tools.md#step-2-implement-the-handler) for usage in tools. +### Where Endpoint Code Lives + +Endpoint wrappers live in the shared client, +`packages/toolkit-core/src/api-client/endpoints/.ts`, so the CLI and +the MCP can call the same one. A wrapper takes an `ApiTarget` (the request +core and the region origin), its parameters, and a response schema from the +host, and returns what the schema parses: + +- A read with a `@sentry/api` operation builds its path and query through the + operation, on `core.sdkClient`. Lists page through `listPage`. +- Anything else, including writes and routes `@sentry/api` lacks, builds its + path with `apiPath` and sends with `core.request` or `core.requestJson`. + +`SentryApiService` methods pass `this.target(opts?.host)` and their Zod schema +to the wrapper. Shaping only the MCP needs, such as unwrapping a `{ data }` +envelope or picking one entry of a keyed result, stays in the method. +The host builds the core from its own transport (credentials, timeouts, +retries), request headers, and logger, so wrappers never import host code. + ### Common Operations ```typescript diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index a26e26eff..7860157be 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -63,6 +63,12 @@ import { getProfileChunks, getTransactionProfile, } from "@sentry/toolkit-core/api-client/endpoints/profiling"; +import { + getReplay, + listIssueReplayIds, + searchReplays, + visitReplayRecordingSegments, +} from "@sentry/toolkit-core/api-client/endpoints/replays"; import { getLatestBaseSnapshot, getSnapshot, @@ -299,9 +305,6 @@ import { readJsonResponse } from "@sentry/toolkit-core/api-client/response"; const SENTRY_MCP_SEARCH_EVENTS_REFERRER = "api.mcp.search-events"; -// The recording-segments endpoint caps per_page at 100. -const REPLAY_SEGMENTS_PER_PAGE = 100; - /** * Filters on other events in the same trace, e.g. spans whose trace also has a * matching log. Only the spans and logs datasets support them. @@ -2913,18 +2916,7 @@ export class SentryApiService { } async searchReplays( - { - organizationSlug, - query, - limit, - projectId, - sort, - environment, - statsPeriod, - start, - end, - fields, - }: { + params: { organizationSlug: string; query?: string; limit?: number; @@ -2938,45 +2930,12 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - - if (query) { - searchQuery.set("query", query); - } - if (limit !== undefined) { - searchQuery.set("per_page", String(limit)); - } - if (projectId) { - searchQuery.append("project", projectId); - } - if (sort) { - searchQuery.set("sort", sort); - } - if (environment) { - const environments = Array.isArray(environment) - ? environment - : [environment]; - for (const value of environments) { - searchQuery.append("environment", value); - } - } - if (fields && fields.length > 0) { - for (const field of fields) { - searchQuery.append("field", field); - } - } - applyTimeParams(searchQuery, statsPeriod, start, end); - - const body = await this.requestJSON( - searchQuery.toString() - ? apiPath`/organizations/${organizationSlug}/replays/` + - `?${searchQuery.toString()}` - : apiPath`/organizations/${organizationSlug}/replays/`, - undefined, - opts, + const response = await searchReplays( + this.target(opts?.host), + params, + ReplayListResponseSchema, ); - - return ReplayListResponseSchema.parse(body).data; + return response.data; } /** @@ -4018,52 +3977,34 @@ export class SentryApiService { } async getReplayDetails( - { - organizationSlug, - replayId, - }: { + params: { organizationSlug: string; replayId: string; }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/replays/${replayId}/`, - undefined, - opts, + const response = await getReplay( + this.target(opts?.host), + params, + z.object({ data: ReplayDetailsSchema }), ); - return z.object({ data: ReplayDetailsSchema }).parse(body).data; + return response.data; } async listReplayIdsForIssue( - { - organizationSlug, - issueId, - dataSource, - }: { + params: { organizationSlug: string; issueId: string | number; dataSource: "discover" | "search_issues"; }, opts?: RequestOptions, ): Promise { - const normalizedIssueId = String(issueId); - const queryParams = new URLSearchParams(); - queryParams.set("returnIds", "true"); - queryParams.set("query", `issue.id:[${normalizedIssueId}]`); - queryParams.set("data_source", dataSource); - queryParams.set("statsPeriod", "90d"); - queryParams.append("project", "-1"); - - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/replay-count/` + - `?${queryParams.toString()}`, - undefined, - opts, + const replayIdsByResource = await listIssueReplayIds( + this.target(opts?.host), + params, + ReplayIdsByResourceSchema, ); - - const replayIdsByResource = ReplayIdsByResourceSchema.parse(body); - return replayIdsByResource[normalizedIssueId] ?? []; + return replayIdsByResource[String(params.issueId)] ?? []; } /** @@ -4076,12 +4017,7 @@ export class SentryApiService { * only keep a small projection of each one. */ async visitReplayRecordingSegments( - { - organizationSlug, - projectSlugOrId, - replayId, - maxSegments, - }: { + params: { organizationSlug: string; projectSlugOrId: string; replayId: string; @@ -4090,39 +4026,12 @@ export class SentryApiService { onSegment: (events: unknown[]) => void, opts?: RequestOptions, ): Promise<{ segmentsRead: number; truncated: boolean }> { - let cursor: string | null = null; - let segmentsRead = 0; - - do { - const params = new URLSearchParams(); - params.set("per_page", String(REPLAY_SEGMENTS_PER_PAGE)); - if (cursor) { - params.set("cursor", cursor); - } - - const response = await this.request( - apiPath`/projects/${organizationSlug}/${projectSlugOrId}/replays/${replayId}/recording-segments/` + - `?${params.toString()}`, - { method: "GET" }, - opts, - ); - const page = ReplayRecordingSegmentPageSchema.parse( - await readJsonResponse(response), - ); - for (const segment of page) { - if (segmentsRead >= maxSegments) { - return { segmentsRead, truncated: true }; - } - onSegment(segment); - segmentsRead += 1; - } - cursor = getNextCursor(response.headers.get("link")); - if (cursor && segmentsRead >= maxSegments) { - return { segmentsRead, truncated: true }; - } - } while (cursor); - - return { segmentsRead, truncated: false }; + return await visitReplayRecordingSegments( + this.target(opts?.host), + params, + ReplayRecordingSegmentPageSchema, + onSegment, + ); } async updateIssue( diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 956b1e76c..7e3b80b5d 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -77,6 +77,9 @@ the one exception. `getProfileChunks`. - `endpoints/snapshots`: preprod snapshots (`getSnapshot`, `getSnapshotImage`, `getLatestBaseSnapshot`), returned unvalidated. + - `endpoints/replays`: `searchReplays`, `getReplay`, `listIssueReplayIds`, + and `visitReplayRecordingSegments`, which follows the cursor up to a + segment limit. - `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/replays.test.ts b/packages/toolkit-core/src/api-client/endpoints/replays.test.ts new file mode 100644 index 000000000..8b6d8e8f8 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/replays.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + nextLink, + ORIGIN, + scriptedTarget, +} from "../../test-utils/api-target.js"; +import { + listIssueReplayIds, + searchReplays, + visitReplayRecordingSegments, +} from "./replays.js"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; +const SegmentPage = z.array(z.array(z.unknown())); +const segments = { + organizationSlug: "acme", + projectSlugOrId: "web", + replayId: "r1", +}; +const segmentsPath = "/projects/acme/web/replays/r1/recording-segments/"; + +describe("searchReplays", () => { + it("sends every filter, repeating environments and fields", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ data: [] })); + + await searchReplays( + apiTarget, + { + organizationSlug: "acme", + query: "user.email:a@b.c", + limit: 5, + projectId: "7", + sort: "-started_at", + environment: ["prod", "staging"], + fields: ["id", "duration"], + statsPeriod: "7d", + }, + AnyJson, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${API}/organizations/acme/replays/?query=user.email%3Aa%40b.c&per_page=5&project=7&sort=-started_at&environment=prod&environment=staging&field=id&field=duration&statsPeriod=7d`, + ); + }); +}); + +describe("listIssueReplayIds", () => { + it("asks for the issue's replay IDs over 90 days in all projects", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({})); + + await listIssueReplayIds( + apiTarget, + { organizationSlug: "acme", issueId: 42, dataSource: "discover" }, + AnyJson, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${API}/organizations/acme/replay-count/?returnIds=true&query=issue.id%3A%5B42%5D&data_source=discover&statsPeriod=90d&project=-1`, + ); + }); +}); + +describe("visitReplayRecordingSegments", () => { + it("follows the cursor and visits each segment", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse([["a"], ["b"]], nextLink(segmentsPath, "0:100:0")), + jsonResponse([["c"]]), + ); + const onSegment = vi.fn(); + + const result = await visitReplayRecordingSegments( + apiTarget, + { ...segments, maxSegments: 10 }, + SegmentPage, + onSegment, + ); + + expect(result).toEqual({ segmentsRead: 3, truncated: false }); + expect(onSegment.mock.calls).toEqual([[["a"]], [["b"]], [["c"]]]); + expect(request.mock.calls.map(([url]) => url)).toEqual([ + `${API}${segmentsPath}?per_page=100`, + `${API}${segmentsPath}?per_page=100&cursor=0%3A100%3A0`, + ]); + }); + + it("stops at the segment limit and reports truncation", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse([["a"], ["b"]], nextLink(segmentsPath, "0:100:0")), + ); + const onSegment = vi.fn(); + + const result = await visitReplayRecordingSegments( + apiTarget, + { ...segments, maxSegments: 2 }, + SegmentPage, + onSegment, + ); + + expect(result).toEqual({ segmentsRead: 2, truncated: true }); + expect(onSegment).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/replays.ts b/packages/toolkit-core/src/api-client/endpoints/replays.ts new file mode 100644 index 000000000..cf1b5c88f --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/replays.ts @@ -0,0 +1,179 @@ +/** + * Session replay 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, nextPageCursor } from "../core.js"; +import { readJsonResponse } from "../response.js"; +import { applyTimeParams } from "../time-params.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +/** The recording-segments endpoint caps `per_page` at 100. */ +const REPLAY_SEGMENTS_PER_PAGE = 100; + +export type SearchReplaysParams = { + organizationSlug: string; + query?: string; + /** Page size. */ + limit?: number; + /** Numeric project ID. */ + projectId?: string; + sort?: string; + environment?: string | string[]; + statsPeriod?: string; + start?: string; + end?: string; + /** Fields to return. */ + fields?: string[]; +}; + +/** The first page of the organization's replays, in the `{ data }` envelope. */ +export async function searchReplays( + { core, origin }: ApiTarget, + { + organizationSlug, + query, + limit, + projectId, + sort, + environment, + statsPeriod, + start, + end, + fields, + }: SearchReplaysParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (query) { + params.set("query", query); + } + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + if (projectId) { + params.append("project", projectId); + } + if (sort) { + params.set("sort", sort); + } + if (environment) { + const environments = Array.isArray(environment) + ? environment + : [environment]; + for (const value of environments) { + params.append("environment", value); + } + } + for (const field of fields ?? []) { + params.append("field", field); + } + applyTimeParams(params, statsPeriod, start, end); + const body = await core.requestJson( + origin, + withQuery(apiPath`/organizations/${organizationSlug}/replays/`, params), + ); + return validateResponse(schema, body); +} + +/** One replay, in the `{ data }` envelope. */ +export async function getReplay( + { core, origin }: ApiTarget, + { + organizationSlug, + replayId, + }: { organizationSlug: string; replayId: string }, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/replays/${replayId}/`, + ); + return validateResponse(schema, body); +} + +export type IssueReplayIdsParams = { + organizationSlug: string; + issueId: string | number; + dataSource: "discover" | "search_issues"; +}; + +/** + * The IDs of an issue's replays in the last 90 days, across all projects, + * keyed by issue ID. + */ +export async function listIssueReplayIds( + { core, origin }: ApiTarget, + { organizationSlug, issueId, dataSource }: IssueReplayIdsParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + params.set("returnIds", "true"); + params.set("query", `issue.id:[${String(issueId)}]`); + params.set("data_source", dataSource); + params.set("statsPeriod", "90d"); + params.append("project", "-1"); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/replay-count/` + + `?${params.toString()}`, + ); + return validateResponse(schema, body); +} + +export type ReplayRecordingSegmentsParams = { + organizationSlug: string; + projectSlugOrId: string; + replayId: string; + /** Most segments to visit. */ + maxSegments: number; +}; + +/** + * Visit a replay's recording segments page by page, following the `Link` + * cursor. Segments go to `onSegment` as each page arrives instead of being + * collected, because full DOM snapshots make raw pages large. + */ +export async function visitReplayRecordingSegments( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlugOrId, + replayId, + maxSegments, + }: ReplayRecordingSegmentsParams, + pageSchema: ResponseSchema, + onSegment: (segment: T) => void, +): Promise<{ segmentsRead: number; truncated: boolean }> { + let cursor: string | null = null; + let segmentsRead = 0; + + do { + const params = new URLSearchParams(); + params.set("per_page", String(REPLAY_SEGMENTS_PER_PAGE)); + if (cursor) { + params.set("cursor", cursor); + } + const response = await core.request( + origin, + apiPath`/projects/${organizationSlug}/${projectSlugOrId}/replays/${replayId}/recording-segments/` + + `?${params.toString()}`, + { method: "GET" }, + ); + const page = validateResponse(pageSchema, await readJsonResponse(response)); + for (const segment of page) { + if (segmentsRead >= maxSegments) { + return { segmentsRead, truncated: true }; + } + onSegment(segment); + segmentsRead += 1; + } + cursor = nextPageCursor(response); + if (cursor && segmentsRead >= maxSegments) { + return { segmentsRead, truncated: true }; + } + } while (cursor); + + return { segmentsRead, truncated: false }; +}