Skip to content
Open
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
9 changes: 6 additions & 3 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ import {
TeamSchema,
TraceMetaSchema,
TraceSchema,
TransactionProfileSchema,
TransactionProfileResponseSchema,
UptimeCheckListSchema,
UptimeMonitorListSchema,
UptimeMonitorSchema,
Expand Down Expand Up @@ -291,6 +291,7 @@ import type {
Trace,
TraceMeta,
TransactionProfile,
LegacySpeedscopeProfile,
UptimeCheckList,
UptimeMonitor,
UptimeMonitorList,
Expand Down Expand Up @@ -4932,11 +4933,13 @@ export class SentryApiService {
profileId: string;
},
opts?: RequestOptions,
): Promise<TransactionProfile> {
): Promise<TransactionProfile | LegacySpeedscopeProfile> {
// Legacy (e.g. Android) profiles are returned as speedscope output even
// when `format=sample` is requested, so they have no `profile` key.
return await getTransactionProfile(
this.target(opts?.host),
params,
TransactionProfileSchema,
TransactionProfileResponseSchema,
);
}

Expand Down
77 changes: 77 additions & 0 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2177,6 +2177,57 @@ const ProfileReleaseSchema = z
])
.optional();

/**
* Schema for a legacy transaction profile response in speedscope format.
*
* The profiling service only honors `format=sample` for sample-format
* profiles. Legacy profiles (Android, React Native on Android) always come
* back as speedscope output: there is no `profile` key, only `profiles`,
* `shared`, and camelCase metadata. We only model the summary metadata here;
* the speedscope stack data is kept opaque.
*/
export const LegacySpeedscopeProfileSchema = z
.object({
platform: z.string(),
profileID: z.string().optional(),
transactionName: z.string().nullable().optional(),
durationNS: z
.union([z.string(), z.number()])
.transform((value) => Number(value))
.optional(),
profiles: z.array(z.unknown()),
shared: z.unknown().optional(),
metadata: z
.object({
deviceClassification: z.string().nullable().optional(),
deviceManufacturer: z.string().nullable().optional(),
deviceModel: z.string().nullable().optional(),
deviceOSName: z.string().nullable().optional(),
deviceOSVersion: z.string().nullable().optional(),
environment: z.string().nullable().optional(),
traceID: z.string().nullable().optional(),
transactionID: z.string().nullable().optional(),
transactionName: z.string().nullable().optional(),
})
.passthrough()
.optional(),
})
.passthrough()
.transform((profile) => ({ ...profile, format: "speedscope" as const }));

/**
* Returns true when a raw profile response body is speedscope output, which
* has `profiles` instead of a sample-format `profile` object.
*/
function isSpeedscopeProfilePayload(body: unknown): boolean {
return (
typeof body === "object" &&
body !== null &&
!("profile" in body) &&
Array.isArray((body as { profiles?: unknown }).profiles)
);
}

/**
* Schema for a V1 transaction profile response.
*
Expand Down Expand Up @@ -2262,6 +2313,32 @@ export const TransactionProfileSchema = z
})
.passthrough();

/**
* Schema for a transaction profile details response.
*
* Validates speedscope bodies against `LegacySpeedscopeProfileSchema` and every
* other body against the strict `TransactionProfileSchema`, so a malformed
* sample-format profile still reports the sample-format issues.
*/
export const TransactionProfileResponseSchema = z
.unknown()
.transform((body, ctx) => {
const result = isSpeedscopeProfilePayload(body)
? LegacySpeedscopeProfileSchema.safeParse(body)
: TransactionProfileSchema.safeParse(body);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({
code: "custom",
message: issue.message,
path: issue.path,
});
}
return z.NEVER;
}
return result.data;
});

export const AgenticOnboardingStageSchema = z.enum([
"connect_mcp",
"analyze_project",
Expand Down
10 changes: 10 additions & 0 deletions packages/mcp-core/src/api-client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ import type {
TransactionEventSchema,
TransactionProfileSampleSchema,
TransactionProfileSchema,
LegacySpeedscopeProfileSchema,
UnknownEventSchema,
UptimeCheckListSchema,
UptimeCheckSchema,
Expand Down Expand Up @@ -308,6 +309,15 @@ export type ProfileChunk = z.infer<typeof ProfileChunkSchema>;
export type ProfileChunkResponse = z.infer<typeof ProfileChunkResponseSchema>;
export type ProfileChunkSample = z.infer<typeof ProfileChunkSampleSchema>;
export type TransactionProfile = z.infer<typeof TransactionProfileSchema>;
export type LegacySpeedscopeProfile = z.infer<
typeof LegacySpeedscopeProfileSchema
>;

export function isLegacySpeedscopeProfile(
profile: TransactionProfile | LegacySpeedscopeProfile,
): profile is LegacySpeedscopeProfile {
return profile.format === "speedscope";
}
export type TransactionProfileSample = z.infer<
typeof TransactionProfileSampleSchema
>;
Expand Down
82 changes: 82 additions & 0 deletions packages/mcp-core/src/tools/catalog/get-profile-details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,88 @@ describe("get_profile_details", () => {
expect(result).toContain("| `unknown` | main.py:42 | 3 | User Code |");
});

it("summarizes legacy speedscope transaction profiles", async () => {
const legacyProfileId = "ae9108da5e274c6684f9c4e727cd18a3";
const speedscopeBody = {
activeProfileIndex: 0,
durationNS: 1500000000,
metadata: {
deviceManufacturer: "samsung",
deviceModel: "SM-S918N",
deviceOSName: "android",
deviceOSVersion: "14",
environment: "production",
traceID: "18cd3a3d8eae46e5be7337e1e1845d62",
transactionName: "MainActivity",
version: "1",
},
platform: "android",
profileID: legacyProfileId,
profiles: [{ type: "evented", events: [] }],
projectID: 1,
shared: { frames: [] },
transactionName: "MainActivity",
metrics: null,
};
for (const host of ["sentry.io", "us.sentry.io"]) {
mswServer.use(
http.get(
`https://${host}/api/0/projects/sentry-mcp-evals/backend/profiling/profiles/${legacyProfileId}/`,
() => HttpResponse.json(speedscopeBody),
{ once: true },
),
);
}

const result = await getProfileDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
projectSlugOrId: "backend",
profileId: legacyProfileId,
regionUrl: null,
focusOnUserCode: true,
},
baseContext,
);

expect(result).toContain(`# Profile ${legacyProfileId}`);
expect(result).toContain("**Transaction**: MainActivity");
expect(result).toContain("**Platform**: android");
expect(result).toContain("**Format**: legacy (speedscope)");
expect(result).toContain("**Device**: samsung SM-S918N");
expect(result).toContain("## Sample Analysis Unavailable");
});

it("still rejects sample-format profiles without profile data", async () => {
const malformedProfileId = "be9108da5e274c6684f9c4e727cd18a3";
for (const host of ["sentry.io", "us.sentry.io"]) {
mswServer.use(
http.get(
`https://${host}/api/0/projects/sentry-mcp-evals/backend/profiling/profiles/${malformedProfileId}/`,
() =>
HttpResponse.json({
profile_id: malformedProfileId,
platform: "python",
}),
{ once: true },
),
);
}

await expect(
getProfileDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
projectSlugOrId: "backend",
profileId: malformedProfileId,
regionUrl: null,
focusOnUserCode: true,
},
baseContext,
),
).rejects.toThrow("profile:");
});

it("fetches a transaction profile from a numeric project ID", async () => {
mswServer.use(
http.get(
Expand Down
13 changes: 13 additions & 0 deletions packages/mcp-core/src/tools/catalog/get-profile-details.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { isLegacySpeedscopeProfile } from "../../api-client/types";
import { UserInputError } from "../../errors";
import { apiServiceFromContext } from "../../internal/tool-helpers/api";
import { defineTool } from "../../internal/tool-helpers/define";
Expand All @@ -17,6 +18,7 @@ import {
validateSlugOrId,
} from "../../utils/slug-validation";
import {
formatLegacyProfileSummary,
formatProfileChunkAnalysis,
formatTransactionProfileAnalysis,
} from "../support/profile/formatter";
Expand Down Expand Up @@ -352,6 +354,17 @@ export default defineTool({
resolved.profileId,
);

if (isLegacySpeedscopeProfile(profile)) {
const legacyTraceId = profile.metadata?.traceID;
return formatLegacyProfileSummary(profile, {
profileUrl,
projectSlug,
traceUrl: legacyTraceId
? apiService.getTraceUrl(resolved.organizationSlug, legacyTraceId)
: undefined,
});
}

const traceUrl = profile.transaction?.trace_id
? apiService.getTraceUrl(
resolved.organizationSlug,
Expand Down
64 changes: 64 additions & 0 deletions packages/mcp-core/src/tools/support/profile/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import type {
Flamegraph,
LegacySpeedscopeProfile,
ProfileChunk,
TransactionProfile,
} from "../../../api-client/types";
Expand Down Expand Up @@ -493,6 +494,69 @@ export function formatProfileChunkAnalysis(
return sections.join("\n");
}

/**
* Formats a legacy transaction profile returned in speedscope format.
*
* Legacy profiles (e.g. Android) do not expose sample-format data through the
* profile details API, so only summary metadata can be reported here.
*/
export function formatLegacyProfileSummary(
profile: LegacySpeedscopeProfile,
options: Omit<TransactionProfileAnalysisOptions, "focusOnUserCode">,
): string {
const metadata: NonNullable<LegacySpeedscopeProfile["metadata"]> =
profile.metadata ?? {};
const profileId = profile.profileID ?? "Unknown";
const transactionName =
profile.transactionName ?? metadata.transactionName ?? "Unknown";
const device = [metadata.deviceManufacturer, metadata.deviceModel]
.filter(Boolean)
.join(" ");
const os = [metadata.deviceOSName, metadata.deviceOSVersion]
.filter(Boolean)
.join(" ");

const sections: string[] = [
`# Profile ${profileId}`,
"",
"## Summary",
`- **Profile URL**: ${options.profileUrl}`,
`- **Project**: ${options.projectSlug}`,
`- **Profile ID**: ${profileId}`,
`- **Transaction**: ${transactionName}`,
];
if (metadata.traceID) {
sections.push(`- **Trace ID**: ${metadata.traceID}`);
}
if (options.traceUrl) {
sections.push(`- **Trace URL**: ${options.traceUrl}`);
}
if (profile.durationNS != null && Number.isFinite(profile.durationNS)) {
sections.push(`- **Duration**: ${formatDuration(profile.durationNS)}`);
}
sections.push(`- **Platform**: ${profile.platform}`);
sections.push("- **Format**: legacy (speedscope)");
if (metadata.environment) {
sections.push(`- **Environment**: ${metadata.environment}`);
}
if (device) {
sections.push(`- **Device**: ${device}`);
}
if (os) {
sections.push(`- **OS**: ${os}`);
}
sections.push("");
sections.push("## Sample Analysis Unavailable");
sections.push(
"This is a legacy transaction profile (for example from an Android or React Native app). Sentry returns it in speedscope format, which does not include the sample-level frames, stacks, and samples this tool analyzes.",
);
sections.push(
"Open the Profile URL above to inspect the flamegraph in Sentry, or use `get_profile` to analyze aggregated flamegraph data for this transaction.",
);

return sections.join("\n");
}

export function formatTransactionProfileAnalysis(
profile: TransactionProfile,
options: TransactionProfileAnalysisOptions,
Expand Down
Loading