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
170 changes: 44 additions & 126 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4766,27 +4780,14 @@ export class SentryApiService {
* ```
*/
async getTraceMeta(
{
organizationSlug,
traceId,
statsPeriod = "14d",
}: {
params: {
organizationSlug: string;
traceId: string;
statsPeriod?: string;
},
opts?: RequestOptions,
): Promise<TraceMeta> {
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);
}

/**
Expand Down Expand Up @@ -4815,13 +4816,7 @@ export class SentryApiService {
* ```
*/
async getTrace(
{
organizationSlug,
traceId,
limit = 1000,
project = "-1",
statsPeriod = "14d",
}: {
params: {
organizationSlug: string;
traceId: string;
limit?: number;
Expand All @@ -4830,20 +4825,7 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<Trace> {
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);
}

/**
Expand Down Expand Up @@ -5019,53 +5001,34 @@ export class SentryApiService {
* ```
*/
async getFlamegraph(
{
organizationSlug,
projectId,
transactionName,
statsPeriod = "7d",
}: {
params: {
organizationSlug: string;
projectId: string | number;
transactionName: string;
statsPeriod?: string;
},
opts?: RequestOptions,
): Promise<Flamegraph> {
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<TransactionProfile> {
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,
);
}

/**
Expand Down Expand Up @@ -5104,13 +5067,7 @@ export class SentryApiService {
* ```
*/
async getProfileChunk(
{
organizationSlug,
profilerId,
projectId,
start,
end,
}: {
params: {
organizationSlug: string;
profilerId: string;
projectId: string | number;
Expand All @@ -5119,32 +5076,25 @@ export class SentryApiService {
},
opts?: RequestOptions,
): Promise<ProfileChunk> {
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,
);
}

return {
...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,
};
}

Expand All @@ -5157,36 +5107,20 @@ export class SentryApiService {
);
}

async getSnapshotDetails({
organizationSlug,
snapshotId,
compactMetadata = true,
}: {
async getSnapshotDetails(params: {
organizationSlug: string;
snapshotId: string;
compactMetadata?: boolean;
}): Promise<unknown> {
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<unknown> {
const path = apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/${snapshotId}/images/${imageIdentifier}/`;
return await this.requestJSON(path);
return await getSnapshotImage(this.target(), params);
}

async fetchImageByUrl(
Expand All @@ -5213,30 +5147,14 @@ export class SentryApiService {
return { blob, contentType };
}

async getLatestBaseSnapshot({
organizationSlug,
appId,
branch,
project,
projectSlug,
compactMetadata = true,
}: {
async getLatestBaseSnapshot(params: {
organizationSlug: string;
appId: string;
branch?: string;
project?: string;
projectSlug?: string;
compactMetadata?: boolean;
}): Promise<unknown> {
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);
}
}
5 changes: 5 additions & 0 deletions packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
103 changes: 103 additions & 0 deletions packages/toolkit-core/src/api-client/endpoints/profiling.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
{ core, origin }: ApiTarget,
{
organizationSlug,
projectId,
transactionName,
statsPeriod = "7d",
}: GetFlamegraphParams,
schema: ResponseSchema<T>,
): Promise<T> {
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<T>(
{ core, origin }: ApiTarget,
{
organizationSlug,
projectSlugOrId,
profileId,
}: {
organizationSlug: string;
projectSlugOrId: string | number;
profileId: string;
},
schema: ResponseSchema<T>,
): Promise<T> {
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<T>(
{ core, origin }: ApiTarget,
{
organizationSlug,
profilerId,
projectId,
start,
end,
}: GetProfileChunksParams,
schema: ResponseSchema<T>,
): Promise<T> {
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);
}
Loading
Loading