Skip to content
Merged
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
19 changes: 19 additions & 0 deletions docs/contributing/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<domain>.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
Expand Down
153 changes: 31 additions & 122 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -2938,45 +2930,12 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<ReplayList> {
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;
}

/**
Expand Down Expand Up @@ -4018,52 +3977,34 @@ export class SentryApiService {
}

async getReplayDetails(
{
organizationSlug,
replayId,
}: {
params: {
organizationSlug: string;
replayId: string;
},
opts?: RequestOptions,
): Promise<ReplayDetails> {
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<string[]> {
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)] ?? [];
}

/**
Expand All @@ -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;
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
106 changes: 106 additions & 0 deletions packages/toolkit-core/src/api-client/endpoints/replays.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading