From c99648648482abea6f81161c669c333ec4597a31 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:32:06 +0000 Subject: [PATCH] ref(mcp): Call shared trace, profiling, and snapshot endpoints Moves the trace, trace meta, flamegraph, transaction profile, profile chunk, and preprod snapshot endpoints from SentryApiService into toolkit-core's shared API client, as is. SentryApiService keeps the profile chunk unwrapping and profiler_id backfill. Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com> --- packages/mcp-core/src/api-client/client.ts | 170 +++++------------- packages/toolkit-core/README.md | 5 + .../src/api-client/endpoints/profiling.ts | 103 +++++++++++ .../src/api-client/endpoints/snapshots.ts | 88 +++++++++ .../traces-profiling-snapshots.test.ts | 150 ++++++++++++++++ .../src/api-client/endpoints/traces.ts | 64 +++++++ 6 files changed, 454 insertions(+), 126 deletions(-) create mode 100644 packages/toolkit-core/src/api-client/endpoints/profiling.ts create mode 100644 packages/toolkit-core/src/api-client/endpoints/snapshots.ts create mode 100644 packages/toolkit-core/src/api-client/endpoints/traces-profiling-snapshots.test.ts create mode 100644 packages/toolkit-core/src/api-client/endpoints/traces.ts diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 7631f8a9b..a26e26eff 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -58,6 +58,20 @@ import { updateUptimeMonitor, type UptimeMonitorFields, } from "@sentry/toolkit-core/api-client/endpoints/uptime-monitors"; +import { + getFlamegraph, + getProfileChunks, + getTransactionProfile, +} from "@sentry/toolkit-core/api-client/endpoints/profiling"; +import { + getLatestBaseSnapshot, + getSnapshot, + getSnapshotImage, +} from "@sentry/toolkit-core/api-client/endpoints/snapshots"; +import { + getTrace, + getTraceMeta, +} from "@sentry/toolkit-core/api-client/endpoints/traces"; import { getEventCommitters, getRelease, @@ -4766,27 +4780,14 @@ export class SentryApiService { * ``` */ async getTraceMeta( - { - organizationSlug, - traceId, - statsPeriod = "14d", - }: { + params: { organizationSlug: string; traceId: string; statsPeriod?: string; }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("statsPeriod", statsPeriod); - - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/trace-meta/${traceId}/` + - `?${queryParams.toString()}`, - undefined, - opts, - ); - return TraceMetaSchema.parse(body); + return await getTraceMeta(this.target(opts?.host), params, TraceMetaSchema); } /** @@ -4815,13 +4816,7 @@ export class SentryApiService { * ``` */ async getTrace( - { - organizationSlug, - traceId, - limit = 1000, - project = "-1", - statsPeriod = "14d", - }: { + params: { organizationSlug: string; traceId: string; limit?: number; @@ -4830,20 +4825,7 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - // Keep sending the endpoint's declared query parameters even though the - // current server implementation ignores `project` and paginates internally. - queryParams.set("limit", String(limit)); - queryParams.set("project", project); - queryParams.set("statsPeriod", statsPeriod); - - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/trace/${traceId}/` + - `?${queryParams.toString()}`, - undefined, - opts, - ); - return TraceSchema.parse(body); + return await getTrace(this.target(opts?.host), params, TraceSchema); } /** @@ -5019,12 +5001,7 @@ export class SentryApiService { * ``` */ async getFlamegraph( - { - organizationSlug, - projectId, - transactionName, - statsPeriod = "7d", - }: { + params: { organizationSlug: string; projectId: string | number; transactionName: string; @@ -5032,40 +5009,26 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("project", projectId.toString()); - // Escape backslashes first, then quotes for proper string escaping - const escapedTransaction = transactionName - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"'); - queryParams.set( - "query", - `event.type:transaction transaction:"${escapedTransaction}"`, + return await getFlamegraph( + this.target(opts?.host), + params, + FlamegraphSchema, ); - queryParams.set("statsPeriod", statsPeriod); - - const path = - apiPath`/organizations/${organizationSlug}/profiling/flamegraph/` + - `?${queryParams.toString()}`; - const body = await this.requestJSON(path, undefined, opts); - return FlamegraphSchema.parse(body); } async getTransactionProfile( - { - organizationSlug, - projectSlugOrId, - profileId, - }: { + params: { organizationSlug: string; projectSlugOrId: string | number; profileId: string; }, opts?: RequestOptions, ): Promise { - const path = apiPath`/projects/${organizationSlug}/${projectSlugOrId}/profiling/profiles/${profileId}/`; - const body = await this.requestJSON(path, undefined, opts); - return TransactionProfileSchema.parse(body); + return await getTransactionProfile( + this.target(opts?.host), + params, + TransactionProfileSchema, + ); } /** @@ -5104,13 +5067,7 @@ export class SentryApiService { * ``` */ async getProfileChunk( - { - organizationSlug, - profilerId, - projectId, - start, - end, - }: { + params: { organizationSlug: string; profilerId: string; projectId: string | number; @@ -5119,24 +5076,17 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("profiler_id", profilerId); - queryParams.set("project", projectId.toString()); - queryParams.set("start", start); - queryParams.set("end", end); - - const path = - apiPath`/organizations/${organizationSlug}/profiling/chunks/` + - `?${queryParams.toString()}`; - const body = await this.requestJSON(path, undefined, opts); - + const response = await getProfileChunks( + this.target(opts?.host), + params, + ProfileChunkResponseSchema, + ); // Normalize the Sentry {chunk: ...} wrapper to the internal chunks array. - const response = ProfileChunkResponseSchema.parse(body); if (response.chunks.length === 0) { throw new ApiNotFoundError( - `No profile chunk found for profiler_id ${profilerId}`, + `No profile chunk found for profiler_id ${params.profilerId}`, undefined, - body, + response, ); } @@ -5144,7 +5094,7 @@ export class SentryApiService { ...response.chunks[0], // Sentry's frontend does the same query-param backfill for continuous // profile chunks because profiling-service may omit profiler_id. - profiler_id: response.chunks[0].profiler_id ?? profilerId, + profiler_id: response.chunks[0].profiler_id ?? params.profilerId, }; } @@ -5157,36 +5107,20 @@ export class SentryApiService { ); } - async getSnapshotDetails({ - organizationSlug, - snapshotId, - compactMetadata = true, - }: { + async getSnapshotDetails(params: { organizationSlug: string; snapshotId: string; compactMetadata?: boolean; }): Promise { - const params = new URLSearchParams(); - if (compactMetadata) { - params.set("compact_metadata", "true"); - } - const path = - apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/${snapshotId}/` + - `?${params.toString()}`; - return await this.requestJSON(path); + return await getSnapshot(this.target(), params); } - async getSnapshotImageDetail({ - organizationSlug, - snapshotId, - imageIdentifier, - }: { + async getSnapshotImageDetail(params: { organizationSlug: string; snapshotId: string; imageIdentifier: string; }): Promise { - const path = apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/${snapshotId}/images/${imageIdentifier}/`; - return await this.requestJSON(path); + return await getSnapshotImage(this.target(), params); } async fetchImageByUrl( @@ -5213,14 +5147,7 @@ export class SentryApiService { return { blob, contentType }; } - async getLatestBaseSnapshot({ - organizationSlug, - appId, - branch, - project, - projectSlug, - compactMetadata = true, - }: { + async getLatestBaseSnapshot(params: { organizationSlug: string; appId: string; branch?: string; @@ -5228,15 +5155,6 @@ export class SentryApiService { projectSlug?: string; compactMetadata?: boolean; }): Promise { - const params = new URLSearchParams(); - params.set("app_id", appId); - if (branch) params.set("branch", branch); - if (project) params.set("project", project); - if (projectSlug) params.set("projectSlug", projectSlug); - if (compactMetadata) params.set("compact_metadata", "true"); - const path = - apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/latest-base/` + - `?${params.toString()}`; - return await this.requestJSON(path); + return await getLatestBaseSnapshot(this.target(), params); } } diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index b78a2109e..956b1e76c 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -72,6 +72,11 @@ the one exception. - `endpoints/uptime-monitors`: `listUptimeMonitors`, `getUptimeMonitor`, `listUptimeMonitorChecks`, `createUptimeMonitor`, `updateUptimeMonitor`, and `deleteUptimeMonitor`. + - `endpoints/traces`: `getTraceMeta` and `getTrace`. + - `endpoints/profiling`: `getFlamegraph`, `getTransactionProfile`, and + `getProfileChunks`. + - `endpoints/snapshots`: preprod snapshots (`getSnapshot`, + `getSnapshotImage`, `getLatestBaseSnapshot`), returned unvalidated. - `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/profiling.ts b/packages/toolkit-core/src/api-client/endpoints/profiling.ts new file mode 100644 index 000000000..2e2d51848 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/profiling.ts @@ -0,0 +1,103 @@ +/** + * Profiling endpoints. Each wrapper validates the response with the schema + * the host passes, so the host's schema decides the result type. + */ + +import { apiPath } from "../api-path.js"; +import type { ApiTarget } from "../core.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +export type GetFlamegraphParams = { + organizationSlug: string; + projectId: string | number; + /** Matched exactly; quotes and backslashes are escaped. */ + transactionName: string; + /** Default: `7d`. */ + statsPeriod?: string; +}; + +/** The aggregated flamegraph of a transaction's profiles. */ +export async function getFlamegraph( + { core, origin }: ApiTarget, + { + organizationSlug, + projectId, + transactionName, + statsPeriod = "7d", + }: GetFlamegraphParams, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + query.set("project", projectId.toString()); + // Escape backslashes first, then quotes. + const escapedTransaction = transactionName + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + query.set( + "query", + `event.type:transaction transaction:"${escapedTransaction}"`, + ); + query.set("statsPeriod", statsPeriod); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/profiling/flamegraph/` + + `?${query.toString()}`, + ); + return validateResponse(schema, body); +} + +/** One transaction profile. */ +export async function getTransactionProfile( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlugOrId, + profileId, + }: { + organizationSlug: string; + projectSlugOrId: string | number; + profileId: string; + }, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlugOrId}/profiling/profiles/${profileId}/`, + ); + return validateResponse(schema, body); +} + +export type GetProfileChunksParams = { + organizationSlug: string; + profilerId: string; + projectId: string | number; + /** ISO timestamp. */ + start: string; + /** ISO timestamp. */ + end: string; +}; + +/** The continuous profile chunks of a profiler in a time range. */ +export async function getProfileChunks( + { core, origin }: ApiTarget, + { + organizationSlug, + profilerId, + projectId, + start, + end, + }: GetProfileChunksParams, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + query.set("profiler_id", profilerId); + query.set("project", projectId.toString()); + query.set("start", start); + query.set("end", end); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/profiling/chunks/` + + `?${query.toString()}`, + ); + return validateResponse(schema, body); +} diff --git a/packages/toolkit-core/src/api-client/endpoints/snapshots.ts b/packages/toolkit-core/src/api-client/endpoints/snapshots.ts new file mode 100644 index 000000000..8fb72d19b --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/snapshots.ts @@ -0,0 +1,88 @@ +/** + * Preprod snapshot endpoints. The responses are returned unvalidated: the + * callers read them as unknown JSON. + */ + +import { apiPath, withQuery } from "../api-path.js"; +import type { ApiTarget } from "../core.js"; + +/** One snapshot, with compact image metadata unless `compactMetadata` is false. */ +export async function getSnapshot( + { core, origin }: ApiTarget, + { + organizationSlug, + snapshotId, + compactMetadata = true, + }: { + organizationSlug: string; + snapshotId: string; + compactMetadata?: boolean; + }, +): Promise { + const query = new URLSearchParams(); + if (compactMetadata) { + query.set("compact_metadata", "true"); + } + return await core.requestJson( + origin, + withQuery( + apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/${snapshotId}/`, + query, + ), + ); +} + +/** One image of a snapshot. */ +export async function getSnapshotImage( + { core, origin }: ApiTarget, + { + organizationSlug, + snapshotId, + imageIdentifier, + }: { + organizationSlug: string; + snapshotId: string; + imageIdentifier: string; + }, +): Promise { + return await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/${snapshotId}/images/${imageIdentifier}/`, + ); +} + +export type GetLatestBaseSnapshotParams = { + organizationSlug: string; + appId: string; + branch?: string; + /** Numeric project ID. */ + project?: string; + projectSlug?: string; + /** Default: true. */ + compactMetadata?: boolean; +}; + +/** The latest base snapshot of an app, optionally on one branch. */ +export async function getLatestBaseSnapshot( + { core, origin }: ApiTarget, + { + organizationSlug, + appId, + branch, + project, + projectSlug, + compactMetadata = true, + }: GetLatestBaseSnapshotParams, +): Promise { + const query = new URLSearchParams(); + query.set("app_id", appId); + if (branch) query.set("branch", branch); + if (project) query.set("project", project); + if (projectSlug) query.set("projectSlug", projectSlug); + if (compactMetadata) query.set("compact_metadata", "true"); + return await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/latest-base/` + + `?${query.toString()}`, + ); +} diff --git a/packages/toolkit-core/src/api-client/endpoints/traces-profiling-snapshots.test.ts b/packages/toolkit-core/src/api-client/endpoints/traces-profiling-snapshots.test.ts new file mode 100644 index 000000000..067151ed3 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/traces-profiling-snapshots.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../../test-utils/api-target.js"; +import { + getFlamegraph, + getProfileChunks, + getTransactionProfile, +} from "./profiling.js"; +import { + getLatestBaseSnapshot, + getSnapshot, + getSnapshotImage, +} from "./snapshots.js"; +import { getTrace, getTraceMeta } from "./traces.js"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; +const trace = { organizationSlug: "acme", traceId: "abc123" }; + +/** 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("traces", () => { + it("sends the default period for trace meta", async () => { + expect( + await requestedUrl((target) => getTraceMeta(target, trace, AnyJson)), + ).toBe(`${API}/organizations/acme/trace-meta/abc123/?statsPeriod=14d`); + }); + + it("sends the declared trace parameters with their defaults", async () => { + expect( + await requestedUrl((target) => + getTrace(target, { ...trace, statsPeriod: "7d" }, AnyJson), + ), + ).toBe( + `${API}/organizations/acme/trace/abc123/?limit=1000&project=-1&statsPeriod=7d`, + ); + }); +}); + +describe("profiling", () => { + it("escapes the transaction name in the flamegraph query", async () => { + const url = await requestedUrl((target) => + getFlamegraph( + target, + { + organizationSlug: "acme", + projectId: 7, + transactionName: 'GET /a\\b "c"', + }, + AnyJson, + ), + ); + + const query = new URL(url ?? "").searchParams; + expect(query.get("project")).toBe("7"); + expect(query.get("query")).toBe( + 'event.type:transaction transaction:"GET /a\\\\b \\"c\\""', + ); + expect(query.get("statsPeriod")).toBe("7d"); + }); + + it("requests a transaction profile by project", async () => { + expect( + await requestedUrl((target) => + getTransactionProfile( + target, + { organizationSlug: "acme", projectSlugOrId: "web", profileId: "p1" }, + AnyJson, + ), + ), + ).toBe(`${API}/projects/acme/web/profiling/profiles/p1/`); + }); + + it("sends the profiler and range for chunks", async () => { + expect( + await requestedUrl((target) => + getProfileChunks( + target, + { + organizationSlug: "acme", + profilerId: "prof", + projectId: 7, + start: "2026-01-01T00:00:00Z", + end: "2026-01-01T01:00:00Z", + }, + AnyJson, + ), + ), + ).toBe( + `${API}/organizations/acme/profiling/chunks/?profiler_id=prof&project=7&start=2026-01-01T00%3A00%3A00Z&end=2026-01-01T01%3A00%3A00Z`, + ); + }); +}); + +describe("snapshots", () => { + const snapshots = `${API}/organizations/acme/preprodartifacts/snapshots`; + + it.each([ + [{}, `${snapshots}/s1/?compact_metadata=true`], + [{ compactMetadata: false }, `${snapshots}/s1/`], + ])("requests a snapshot with %o", async (params, url) => { + expect( + await requestedUrl((target) => + getSnapshot(target, { + organizationSlug: "acme", + snapshotId: "s1", + ...params, + }), + ), + ).toBe(url); + }); + + it("requests one snapshot image", async () => { + expect( + await requestedUrl((target) => + getSnapshotImage(target, { + organizationSlug: "acme", + snapshotId: "s1", + imageIdentifier: "home.png", + }), + ), + ).toBe(`${snapshots}/s1/images/home.png/`); + }); + + it("sends the set filters for the latest base snapshot", async () => { + expect( + await requestedUrl((target) => + getLatestBaseSnapshot(target, { + organizationSlug: "acme", + appId: "com.example", + branch: "main", + projectSlug: "ios", + }), + ), + ).toBe( + `${snapshots}/latest-base/?app_id=com.example&branch=main&projectSlug=ios&compact_metadata=true`, + ); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/traces.ts b/packages/toolkit-core/src/api-client/endpoints/traces.ts new file mode 100644 index 000000000..99a463d5a --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/traces.ts @@ -0,0 +1,64 @@ +/** + * Trace endpoints. Each wrapper validates the response with the schema the + * host passes, so the host's schema decides the result type. + */ + +import { apiPath } from "../api-path.js"; +import type { ApiTarget } from "../core.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +export type TraceParams = { + organizationSlug: string; + /** 32-character hex trace ID. */ + traceId: string; + /** Default: `14d`. */ + statsPeriod?: string; +}; + +/** Span, error, and operation counts for a trace. */ +export async function getTraceMeta( + { core, origin }: ApiTarget, + { organizationSlug, traceId, statsPeriod = "14d" }: TraceParams, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams({ statsPeriod }); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/trace-meta/${traceId}/` + + `?${query.toString()}`, + ); + return validateResponse(schema, body); +} + +/** + * The trace's span tree. `limit` and `project` are the endpoint's declared + * parameters; the server currently ignores `project` and paginates + * internally, but they are still sent. + */ +export async function getTrace( + { core, origin }: ApiTarget, + { + organizationSlug, + traceId, + limit = 1000, + project = "-1", + statsPeriod = "14d", + }: TraceParams & { + /** Span-count hint. Default: 1000. */ + limit?: number; + /** Project filter hint. Default: `-1` (all projects). */ + project?: string; + }, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + query.set("limit", String(limit)); + query.set("project", project); + query.set("statsPeriod", statsPeriod); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/trace/${traceId}/` + + `?${query.toString()}`, + ); + return validateResponse(schema, body); +}