Skip to content

Commit 5cefae0

Browse files
fix(profile): split V1 and V2 profile sample schemas (#909)
## Summary Fixes the ZodError surfaced in [MCP-SERVER-FRN](https://sentry.sentry.io/issues/7420869276/) when parsing Sentry's transaction profile responses: ``` Failed to validate keys: profile.samples.<array>.thread_id, profile.samples.<array>.timestamp, transaction.active_thread_id ``` Sentry's profiling service (vroom) emits two distinct sample shapes, but the previous `ProfileSampleSchema` only accepted the V2 one: | | V1 transaction profile | V2 continuous profile chunk | | --- | --- | --- | | `Sample.thread_id` | `uint64` (number) | `string` | | time field | `elapsed_since_start_ns` (uint64 ns) | `timestamp` (seconds, float) | | `Transaction.active_thread_id` | `uint64` (number) | n/a | Rather than make one permissive schema accept both, this PR splits them into discriminated schemas that each describe exactly one wire format — an unexpected V1 payload can't silently match V2 and vice versa. ### Changes - `schema.ts` - New `ProfileChunkSampleSchema` (V2): strict `thread_id: string`, required `timestamp: number`. Used by `ProfileChunkSchema`. - New `TransactionProfileSampleSchema` (V1): `thread_id: string | number → string` via `.transform(String)`, **required** `elapsed_since_start_ns: number`. No `timestamp` field — vroom's V1 wire format never has one. Used by `TransactionProfileSchema`. - `TransactionProfileSchema.profile` no longer reuses `ProfileChunkSchema.shape.profile`; it gets its own `profile` object wired to `TransactionProfileSampleSchema`. - `TransactionProfileSchema.transaction.active_thread_id` accepts `string | number` and normalizes to `string`. - Factored shared `ProfileThreadMetadataSchema`. - `types.ts` – removed `ProfileSample`, added `ProfileChunkSample` and `TransactionProfileSample` (derived via `z.infer`). - `formatter.ts` - `ProfileSampleData` is now a narrow structural type (`{ stack_id; thread_id }`) so both V1 and V2 samples flow through the shared sample/frames rendering without coupling their timing fields. - `getTransactionProfileDurationNs` now derives duration directly from `elapsed_since_start_ns` after the `relative_start_ns`/`relative_end_ns` path. The old `timestamp`-based inference and `inferSampleTimestampNsScale` helper are gone — they were only reachable when V1 samples carried a `timestamp`, which the wire format never does. - Fixtures - Renamed `profile-details.json` → `transaction-profile-v1.json` (and the exported `profileDetailsFixture` → `transactionProfileV1Fixture`) to make the wire format explicit at the name level. References updated across `mcp-server-mocks` (imports, MSW handlers, barrel exports) and all consumer tests in `mcp-core`. - Fixture content now reflects vroom's actual V1 wire format end-to-end: numeric `Sample.thread_id`, `elapsed_since_start_ns` timing, **and** numeric `transaction.active_thread_id` (previously left as `"1"`, which made the `active_thread_id` transform test trivially pass). The existing `profile-chunk.json` continues to serve as the canonical V2 fixture. - Tests - New `ProfileChunkSampleSchema` tests including negative cases that reject V1-only fields. - New `TransactionProfileSampleSchema` tests including a negative case that rejects samples missing `elapsed_since_start_ns` (i.e. rejects a V2-shaped payload on the V1 path). - Round-trip tests that parse `transaction-profile-v1.json` through `TransactionProfileSchema` and `profile-chunk.json` through `ProfileChunkSchema`, guarding against fixture drift. - Formatter regression test for the V1 `elapsed_since_start_ns` duration fallback; legacy `timestamp`-based formatter tests were updated to use `elapsed_since_start_ns` (or removed where no longer reachable). - `createMockTransactionProfile()` parses the fixture through `TransactionProfileSchema.parse()` so `.transform(String)` runs on both the sample-level and transaction-level ids (matching the production code path; addresses Cursor Bugbot's earlier finding). ## Review & Testing Checklist for Human - [ ] Both schemas are now strict about their respective wire formats (V2 requires `timestamp`, V1 requires `elapsed_since_start_ns`, neither accepts the other's time field). If any real-world payload historically deviated from this — e.g. a V1 sample missing `elapsed_since_start_ns`, or a V2 chunk with a numeric `thread_id` — it will now fail validation where the old permissive schema succeeded. Confirm that's acceptable, or relax the specific field back to `.optional()`. - [ ] Public type rename: `ProfileSample` is gone, replaced by `ProfileChunkSample` / `TransactionProfileSample`. The fixture export `profileDetailsFixture` is likewise renamed to `transactionProfileV1Fixture`. Repo-wide grep shows no remaining internal users; double-check any downstream consumer outside this repo. - [ ] Fixture rewrite: `transaction-profile-v1.json` is now fully V1-shaped (numeric ids at both the sample and transaction level, ns offsets). Evals / resource snapshots that render this fixture may need baselines regenerated. - [ ] Optional end-to-end: hit the actual Sentry transaction-profile endpoint in the staging MCP server and confirm the original ZodError is gone. ### Notes - Quality gate locally: `tsc --noEmit` clean, `biome check` clean, full `vitest run` in `packages/mcp-core` green (905 passed / 6 skipped). - CI is all green on the head commit (17 checks, including Cursor Bugbot with no findings). - `thread_metadata` keys are strings — vroom's Go `map[string]ThreadMetadata` serialization — which is why `thread_id` / `active_thread_id` are normalized to string. Link to Devin session: https://app.devin.ai/sessions/dec3ca3b8eaf4471a837828e83fe6c25 Requested by: @dcramer --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: David Cramer <dcramer@gmail.com>
1 parent 053880e commit 5cefae0

10 files changed

Lines changed: 310 additions & 114 deletions

File tree

‎packages/mcp-core/src/api-client/schema.test.ts‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { describe, expect, it } from "vitest";
2+
import {
3+
profileChunkFixture,
4+
transactionProfileV1Fixture,
5+
} from "@sentry/mcp-server-mocks";
26
import {
37
ClientKeySchema,
48
EventSchema,
59
FlamegraphSchema,
610
IssueSchema,
11+
ProfileChunkSampleSchema,
12+
ProfileChunkSchema,
713
ReleaseSchema,
814
ReplayDetailsSchema,
15+
TransactionProfileSampleSchema,
16+
TransactionProfileSchema,
917
} from "./schema";
1018

1119
describe("IssueSchema", () => {
@@ -607,3 +615,149 @@ describe("FlamegraphSchema", () => {
607615
expect(flamegraph.profiles[0]?.sample_counts).toEqual([]);
608616
});
609617
});
618+
619+
describe("ProfileChunkSampleSchema", () => {
620+
it("parses V2 continuous profile chunk samples with string thread_id and timestamp", () => {
621+
const sample = ProfileChunkSampleSchema.parse({
622+
stack_id: 0,
623+
thread_id: "1",
624+
timestamp: 1710958503.629,
625+
});
626+
627+
expect(sample.thread_id).toBe("1");
628+
expect(sample.timestamp).toBe(1710958503.629);
629+
});
630+
631+
it("rejects V1-only fields and shapes that don't match the V2 wire format", () => {
632+
// V2 samples must have a string thread_id (not uint64) and a required
633+
// timestamp. Keeping this strict prevents V1 payloads from silently
634+
// parsing as V2 and vice-versa.
635+
expect(() =>
636+
ProfileChunkSampleSchema.parse({
637+
stack_id: 0,
638+
thread_id: 1,
639+
timestamp: 1710958503.629,
640+
}),
641+
).toThrow();
642+
expect(() =>
643+
ProfileChunkSampleSchema.parse({
644+
stack_id: 0,
645+
thread_id: "1",
646+
elapsed_since_start_ns: 50000000,
647+
}),
648+
).toThrow();
649+
});
650+
});
651+
652+
describe("TransactionProfileSampleSchema", () => {
653+
it("parses V1 transaction profile samples with numeric thread_id and elapsed_since_start_ns", () => {
654+
// Regression test for getsentry/sentry-mcp issue MCP-SERVER-FRN: vroom
655+
// serializes V1 Sample.ThreadID as uint64 and uses elapsed_since_start_ns
656+
// rather than timestamp.
657+
const sample = TransactionProfileSampleSchema.parse({
658+
stack_id: 0,
659+
thread_id: 1,
660+
elapsed_since_start_ns: 50000000,
661+
});
662+
663+
expect(sample.thread_id).toBe("1");
664+
expect(sample.elapsed_since_start_ns).toBe(50000000);
665+
});
666+
667+
it("still normalizes V1 payloads that already carry a string thread_id", () => {
668+
const sample = TransactionProfileSampleSchema.parse({
669+
stack_id: 0,
670+
thread_id: "1",
671+
elapsed_since_start_ns: 1_000_000,
672+
});
673+
674+
expect(sample.thread_id).toBe("1");
675+
expect(sample.elapsed_since_start_ns).toBe(1_000_000);
676+
});
677+
678+
it("rejects V1 samples that are missing the required elapsed_since_start_ns", () => {
679+
// vroom always emits Sample.ElapsedSinceStartNS for V1 transaction
680+
// profiles, so make that a hard requirement rather than silently accepting
681+
// a V2-shaped payload on the V1 path.
682+
expect(() =>
683+
TransactionProfileSampleSchema.parse({
684+
stack_id: 0,
685+
thread_id: "1",
686+
timestamp: 1710958503.629,
687+
}),
688+
).toThrow();
689+
});
690+
});
691+
692+
describe("profile fixtures", () => {
693+
it("parses the V1 transaction profile fixture through TransactionProfileSchema", () => {
694+
// transaction-profile-v1.json mirrors what vroom emits for legacy/V1
695+
// transaction profiles: numeric uint64 thread_id, elapsed_since_start_ns
696+
// timing, and a required transaction block with active_thread_id.
697+
const profile = TransactionProfileSchema.parse(
698+
structuredClone(transactionProfileV1Fixture),
699+
);
700+
701+
expect(profile.transaction?.active_thread_id).toBeTypeOf("string");
702+
expect(
703+
profile.profile.samples.every(
704+
(sample) => typeof sample.thread_id === "string",
705+
),
706+
).toBe(true);
707+
expect(
708+
profile.profile.samples.some(
709+
(sample) => typeof sample.elapsed_since_start_ns === "number",
710+
),
711+
).toBe(true);
712+
});
713+
714+
it("parses the V2 continuous profile chunk fixture through ProfileChunkSchema", () => {
715+
// profile-chunk.json mirrors the continuous profiler output: string
716+
// thread_id, absolute timestamp per sample, no transaction block.
717+
const chunk = ProfileChunkSchema.parse(
718+
structuredClone(profileChunkFixture.chunks[0]),
719+
);
720+
721+
expect(
722+
chunk.profile.samples.every(
723+
(sample) => typeof sample.thread_id === "string",
724+
),
725+
).toBe(true);
726+
expect(
727+
chunk.profile.samples.every(
728+
(sample) => typeof sample.timestamp === "number",
729+
),
730+
).toBe(true);
731+
});
732+
});
733+
734+
describe("TransactionProfileSchema", () => {
735+
it("parses V1 transaction profiles with numeric active_thread_id and uint64 sample thread ids", () => {
736+
// Regression test for getsentry/sentry-mcp issue MCP-SERVER-FRN.
737+
const profile = TransactionProfileSchema.parse({
738+
event_id: "cfe78a5c892d4a64a962d837673398d2",
739+
profile_id: "cfe78a5c892d4a64a962d837673398d2",
740+
platform: "python",
741+
version: "2",
742+
profile: {
743+
frames: [{ function: "handle_request", in_app: true }],
744+
samples: [
745+
{ stack_id: 0, thread_id: 1, elapsed_since_start_ns: 0 },
746+
{ stack_id: 0, thread_id: 1, elapsed_since_start_ns: 50000000 },
747+
],
748+
stacks: [[0]],
749+
thread_metadata: { "1": { name: "MainThread" } },
750+
},
751+
transaction: {
752+
name: "/api/users",
753+
trace_id: "a4d1aae7216b47ff8117cf4e09ce9d0a",
754+
id: "7ca573c0f4814912aaa9bdc77d1a7d51",
755+
active_thread_id: 1,
756+
},
757+
});
758+
759+
expect(profile.transaction?.active_thread_id).toBe("1");
760+
expect(profile.profile.samples[0]?.thread_id).toBe("1");
761+
expect(profile.profile.samples[0]?.elapsed_since_start_ns).toBe(0);
762+
});
763+
});

‎packages/mcp-core/src/api-client/schema.ts‎

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,13 +1079,24 @@ export const ProfileFrameSchema = z
10791079
})
10801080
.passthrough();
10811081

1082+
const ProfileThreadMetadataSchema = z.record(
1083+
z
1084+
.object({
1085+
name: z.string().nullable(),
1086+
priority: z.number().nullable().optional(),
1087+
})
1088+
.passthrough(),
1089+
);
1090+
10821091
/**
1083-
* Schema for individual samples in raw profile chunk data.
1092+
* Schema for a single V2 continuous profile chunk sample.
10841093
*
1085-
* Each sample represents a point-in-time snapshot of the call stack,
1086-
* with a reference to the stack_id and thread_id.
1094+
* V2 chunks are produced by the continuous profiler and span multiple
1095+
* transactions. Each sample carries an absolute (or relative-to-chunk) wall
1096+
* clock `timestamp` in seconds and a string `thread_id` matching
1097+
* `thread_metadata` keys.
10871098
*/
1088-
export const ProfileSampleSchema = z
1099+
export const ProfileChunkSampleSchema = z
10891100
.object({
10901101
stack_id: z.number(),
10911102
thread_id: z.string(),
@@ -1095,7 +1106,27 @@ export const ProfileSampleSchema = z
10951106
.passthrough();
10961107

10971108
/**
1098-
* Schema for raw profile chunk data.
1109+
* Schema for a single V1 transaction profile sample.
1110+
*
1111+
* V1 samples are produced per-transaction by Sentry's profiling service
1112+
* (vroom). They differ from V2 chunk samples in two important ways:
1113+
* - `thread_id` is serialized as a Go `uint64` (a number on the wire). It is
1114+
* normalized to a string here so downstream code can compare against the
1115+
* string keys in `thread_metadata`.
1116+
* - Time is carried as `elapsed_since_start_ns` (uint64 nanoseconds since the
1117+
* start of the profile). V1 samples never carry an absolute `timestamp`.
1118+
*/
1119+
export const TransactionProfileSampleSchema = z
1120+
.object({
1121+
stack_id: z.number(),
1122+
thread_id: z.union([z.string(), z.number()]).transform(String),
1123+
elapsed_since_start_ns: z.number(),
1124+
queue_address: z.string().optional(),
1125+
})
1126+
.passthrough();
1127+
1128+
/**
1129+
* Schema for raw V2 continuous-profile chunk data.
10991130
*
11001131
* Contains the raw sampling data including:
11011132
* - frames: All unique stack frames
@@ -1116,16 +1147,9 @@ export const ProfileChunkSchema = z
11161147
version: z.string(),
11171148
profile: z.object({
11181149
frames: z.array(ProfileFrameSchema),
1119-
samples: z.array(ProfileSampleSchema),
1150+
samples: z.array(ProfileChunkSampleSchema),
11201151
stacks: z.array(z.array(z.number())),
1121-
thread_metadata: z.record(
1122-
z
1123-
.object({
1124-
name: z.string().nullable(),
1125-
priority: z.number().nullable().optional(),
1126-
})
1127-
.passthrough(),
1128-
),
1152+
thread_metadata: ProfileThreadMetadataSchema,
11291153
}),
11301154
})
11311155
.passthrough();
@@ -1153,6 +1177,14 @@ const ProfileReleaseSchema = z
11531177
])
11541178
.optional();
11551179

1180+
/**
1181+
* Schema for a V1 transaction profile response.
1182+
*
1183+
* Unlike V2 continuous profile chunks, transaction profiles are scoped to a
1184+
* single transaction and always include a `transaction` object. vroom emits
1185+
* both `Sample.thread_id` and `Transaction.active_thread_id` as `uint64`, so
1186+
* both are accepted as number or string here and normalized to strings.
1187+
*/
11561188
export const TransactionProfileSchema = z
11571189
.object({
11581190
event_id: z.string().optional(),
@@ -1162,13 +1194,21 @@ export const TransactionProfileSchema = z
11621194
platform: z.string(),
11631195
release: ProfileReleaseSchema,
11641196
version: z.union([z.string(), z.number()]).transform(String).optional(),
1165-
profile: ProfileChunkSchema.shape.profile,
1197+
profile: z.object({
1198+
frames: z.array(ProfileFrameSchema),
1199+
samples: z.array(TransactionProfileSampleSchema),
1200+
stacks: z.array(z.array(z.number())),
1201+
thread_metadata: ProfileThreadMetadataSchema,
1202+
}),
11661203
transaction: z
11671204
.object({
11681205
name: z.string().optional(),
11691206
trace_id: z.string().optional(),
11701207
id: z.string().optional(),
1171-
active_thread_id: z.string().optional(),
1208+
active_thread_id: z
1209+
.union([z.string(), z.number()])
1210+
.transform(String)
1211+
.optional(),
11721212
relative_start_ns: z
11731213
.union([z.string(), z.number()])
11741214
.transform((value) => Number(value))

‎packages/mcp-core/src/api-client/types.ts‎

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,47 +45,48 @@ import type {
4545
AutofixRunStateSchema,
4646
ClientKeyListSchema,
4747
ClientKeySchema,
48-
ErrorEventSchema,
4948
DefaultEventSchema,
50-
TransactionEventSchema,
51-
GenericEventSchema,
52-
UnknownEventSchema,
53-
EventSchema,
54-
EventAttachmentSchema,
49+
ErrorEventSchema,
5550
EventAttachmentListSchema,
51+
EventAttachmentSchema,
52+
EventSchema,
53+
ExternalIssueListSchema,
54+
ExternalIssueSchema,
55+
FlamegraphFrameInfoSchema,
56+
FlamegraphFrameSchema,
57+
FlamegraphProfileMetadataSchema,
58+
FlamegraphProfileSchema,
59+
FlamegraphSchema,
60+
GenericEventSchema,
5661
IssueListSchema,
5762
IssueSchema,
5863
IssueTagValuesSchema,
59-
ExternalIssueSchema,
60-
ExternalIssueListSchema,
61-
ReplayDetailsSchema,
62-
ReplayListResponseSchema,
63-
ReplayRecordingSegmentsSchema,
6464
OrganizationListSchema,
6565
OrganizationSchema,
66+
ProfileChunkResponseSchema,
67+
ProfileChunkSampleSchema,
68+
ProfileChunkSchema,
69+
ProfileFrameSchema,
6670
ProjectListSchema,
6771
ProjectSchema,
6872
ReleaseListSchema,
6973
ReleaseSchema,
74+
ReplayDetailsSchema,
75+
ReplayListResponseSchema,
76+
ReplayRecordingSegmentsSchema,
7077
TagListSchema,
7178
TagSchema,
7279
TeamListSchema,
7380
TeamSchema,
81+
TraceIssueSchema,
7482
TraceMetaSchema,
7583
TraceSchema,
7684
TraceSpanSchema,
77-
TraceIssueSchema,
78-
UserSchema,
79-
FlamegraphSchema,
80-
FlamegraphFrameSchema,
81-
FlamegraphFrameInfoSchema,
82-
FlamegraphProfileSchema,
83-
FlamegraphProfileMetadataSchema,
84-
ProfileChunkSchema,
85-
ProfileChunkResponseSchema,
85+
TransactionEventSchema,
86+
TransactionProfileSampleSchema,
8687
TransactionProfileSchema,
87-
ProfileFrameSchema,
88-
ProfileSampleSchema,
88+
UnknownEventSchema,
89+
UserSchema,
8990
} from "./schema";
9091

9192
export type User = z.infer<typeof UserSchema>;
@@ -148,9 +149,12 @@ export type FlamegraphProfileMetadata = z.infer<
148149
>;
149150
export type ProfileChunk = z.infer<typeof ProfileChunkSchema>;
150151
export type ProfileChunkResponse = z.infer<typeof ProfileChunkResponseSchema>;
152+
export type ProfileChunkSample = z.infer<typeof ProfileChunkSampleSchema>;
151153
export type TransactionProfile = z.infer<typeof TransactionProfileSchema>;
154+
export type TransactionProfileSample = z.infer<
155+
typeof TransactionProfileSampleSchema
156+
>;
152157
export type ProfileFrame = z.infer<typeof ProfileFrameSchema>;
153-
export type ProfileSample = z.infer<typeof ProfileSampleSchema>;
154158

155159
// Issue tag values
156160
export type IssueTagValues = z.infer<typeof IssueTagValuesSchema>;

0 commit comments

Comments
 (0)