diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 7860157be..0e3df6a23 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -218,7 +218,7 @@ import { TeamSchema, TraceMetaSchema, TraceSchema, - TransactionProfileSchema, + TransactionProfileResponseSchema, UptimeCheckListSchema, UptimeMonitorListSchema, UptimeMonitorSchema, @@ -291,6 +291,7 @@ import type { Trace, TraceMeta, TransactionProfile, + LegacySpeedscopeProfile, UptimeCheckList, UptimeMonitor, UptimeMonitorList, @@ -4932,11 +4933,13 @@ export class SentryApiService { profileId: string; }, opts?: RequestOptions, - ): Promise { + ): Promise { + // 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, ); } diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 796b3ef52..f49102d9b 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -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. * @@ -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", diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index b8c826cb5..561bc7ccc 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -130,6 +130,7 @@ import type { TransactionEventSchema, TransactionProfileSampleSchema, TransactionProfileSchema, + LegacySpeedscopeProfileSchema, UnknownEventSchema, UptimeCheckListSchema, UptimeCheckSchema, @@ -308,6 +309,15 @@ export type ProfileChunk = z.infer; export type ProfileChunkResponse = z.infer; export type ProfileChunkSample = z.infer; export type TransactionProfile = z.infer; +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 >; diff --git a/packages/mcp-core/src/tools/catalog/get-profile-details.test.ts b/packages/mcp-core/src/tools/catalog/get-profile-details.test.ts index cdb2ee426..b86b627cb 100644 --- a/packages/mcp-core/src/tools/catalog/get-profile-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-profile-details.test.ts @@ -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( diff --git a/packages/mcp-core/src/tools/catalog/get-profile-details.ts b/packages/mcp-core/src/tools/catalog/get-profile-details.ts index fc93060d5..4edeaf62e 100644 --- a/packages/mcp-core/src/tools/catalog/get-profile-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-profile-details.ts @@ -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"; @@ -17,6 +18,7 @@ import { validateSlugOrId, } from "../../utils/slug-validation"; import { + formatLegacyProfileSummary, formatProfileChunkAnalysis, formatTransactionProfileAnalysis, } from "../support/profile/formatter"; @@ -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, diff --git a/packages/mcp-core/src/tools/support/profile/formatter.ts b/packages/mcp-core/src/tools/support/profile/formatter.ts index 2a8bb5f0b..a560d0e15 100644 --- a/packages/mcp-core/src/tools/support/profile/formatter.ts +++ b/packages/mcp-core/src/tools/support/profile/formatter.ts @@ -9,6 +9,7 @@ import type { Flamegraph, + LegacySpeedscopeProfile, ProfileChunk, TransactionProfile, } from "../../../api-client/types"; @@ -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, +): string { + const metadata: NonNullable = + 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,