Skip to content

Commit a36b672

Browse files
authored
test(profiling): Add test utils to validate Profile Chunk envelope (#18170)
This PR adds two utility functions for testing the profile envelope: `validateProfilePayloadMetadata` and `validateProfile`. As More tests are going to be added, I don't want to copy-paste the same tests over and over. Part of #17279
1 parent 3be2092 commit a36b672

File tree

4 files changed

+214
-318
lines changed

4 files changed

+214
-318
lines changed

dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts

Lines changed: 13 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
shouldSkipTracingTest,
77
waitForTransactionRequestOnUrl,
88
} from '../../../utils/helpers';
9+
import { validateProfile } from '../test-utils';
910

1011
sentryTest(
1112
'does not send profile envelope when document-policy is not set',
@@ -41,79 +42,16 @@ sentryTest('sends profile envelope in legacy mode', async ({ page, getLocalTestU
4142
const profile = profileEvent.profile;
4243
expect(profileEvent.profile).toBeDefined();
4344

44-
expect(profile.samples).toBeDefined();
45-
expect(profile.stacks).toBeDefined();
46-
expect(profile.frames).toBeDefined();
47-
expect(profile.thread_metadata).toBeDefined();
48-
49-
// Samples
50-
expect(profile.samples.length).toBeGreaterThanOrEqual(2);
51-
for (const sample of profile.samples) {
52-
expect(typeof sample.elapsed_since_start_ns).toBe('string');
53-
expect(sample.elapsed_since_start_ns).toMatch(/^\d+$/); // Numeric string
54-
expect(parseInt(sample.elapsed_since_start_ns, 10)).toBeGreaterThanOrEqual(0);
55-
56-
expect(typeof sample.stack_id).toBe('number');
57-
expect(sample.stack_id).toBeGreaterThanOrEqual(0);
58-
expect(sample.thread_id).toBe('0'); // Should be main thread
59-
}
60-
61-
// Stacks
62-
expect(profile.stacks.length).toBeGreaterThan(0);
63-
for (const stack of profile.stacks) {
64-
expect(Array.isArray(stack)).toBe(true);
65-
for (const frameIndex of stack) {
66-
expect(typeof frameIndex).toBe('number');
67-
expect(frameIndex).toBeGreaterThanOrEqual(0);
68-
expect(frameIndex).toBeLessThan(profile.frames.length);
69-
}
70-
}
71-
72-
// Frames
73-
expect(profile.frames.length).toBeGreaterThan(0);
74-
for (const frame of profile.frames) {
75-
expect(frame).toHaveProperty('function');
76-
expect(typeof frame.function).toBe('string');
77-
78-
if (frame.function !== 'fetch' && frame.function !== 'setTimeout') {
79-
expect(frame).toHaveProperty('abs_path');
80-
expect(frame).toHaveProperty('lineno');
81-
expect(frame).toHaveProperty('colno');
82-
expect(typeof frame.abs_path).toBe('string');
83-
expect(typeof frame.lineno).toBe('number');
84-
expect(typeof frame.colno).toBe('number');
85-
}
86-
}
87-
88-
const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== '');
89-
90-
if ((process.env.PW_BUNDLE || '').endsWith('min')) {
91-
// Function names are minified in minified bundles
92-
expect(functionNames.length).toBeGreaterThan(0);
93-
expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings
94-
} else {
95-
expect(functionNames).toEqual(
96-
expect.arrayContaining([
97-
'_startRootSpan',
98-
'withScope',
99-
'createChildOrRootSpan',
100-
'startSpanManual',
101-
'startProfileForSpan',
102-
'startJSSelfProfile',
103-
]),
104-
);
105-
}
106-
107-
expect(profile.thread_metadata).toHaveProperty('0');
108-
expect(profile.thread_metadata['0']).toHaveProperty('name');
109-
expect(profile.thread_metadata['0'].name).toBe('main');
110-
111-
// Test that profile duration makes sense (should be > 20ms based on test setup)
112-
const startTime = parseInt(profile.samples[0].elapsed_since_start_ns, 10);
113-
const endTime = parseInt(profile.samples[profile.samples.length - 1].elapsed_since_start_ns, 10);
114-
const durationNs = endTime - startTime;
115-
const durationMs = durationNs / 1_000_000; // Convert ns to ms
116-
117-
// Should be at least 20ms based on our setTimeout(21) in the test
118-
expect(durationMs).toBeGreaterThan(20);
45+
validateProfile(profile, {
46+
expectedFunctionNames: [
47+
'_startRootSpan',
48+
'withScope',
49+
'createChildOrRootSpan',
50+
'startSpanManual',
51+
'startProfileForSpan',
52+
'startJSSelfProfile',
53+
],
54+
minSampleDurationMs: 20,
55+
isChunkFormat: false,
56+
});
11957
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { expect } from '@playwright/test';
2+
import type { ContinuousThreadCpuProfile, ProfileChunk, ThreadCpuProfile } from '@sentry/core';
3+
4+
interface ValidateProfileOptions {
5+
expectedFunctionNames?: string[];
6+
minSampleDurationMs?: number;
7+
isChunkFormat?: boolean;
8+
}
9+
10+
/**
11+
* Validates the metadata of a profile chunk envelope.
12+
* https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/
13+
*/
14+
export function validateProfilePayloadMetadata(profileChunk: ProfileChunk): void {
15+
expect(profileChunk.version).toBe('2');
16+
expect(profileChunk.platform).toBe('javascript');
17+
18+
expect(typeof profileChunk.profiler_id).toBe('string');
19+
expect(profileChunk.profiler_id).toMatch(/^[a-f\d]{32}$/);
20+
21+
expect(typeof profileChunk.chunk_id).toBe('string');
22+
expect(profileChunk.chunk_id).toMatch(/^[a-f\d]{32}$/);
23+
24+
expect(profileChunk.client_sdk).toBeDefined();
25+
expect(typeof profileChunk.client_sdk.name).toBe('string');
26+
expect(typeof profileChunk.client_sdk.version).toBe('string');
27+
28+
expect(typeof profileChunk.release).toBe('string');
29+
30+
expect(profileChunk.debug_meta).toBeDefined();
31+
expect(Array.isArray(profileChunk?.debug_meta?.images)).toBe(true);
32+
}
33+
34+
/**
35+
* Validates the basic structure and content of a Sentry profile.
36+
*/
37+
export function validateProfile(
38+
profile: ThreadCpuProfile | ContinuousThreadCpuProfile,
39+
options: ValidateProfileOptions = {},
40+
): void {
41+
const { expectedFunctionNames, minSampleDurationMs, isChunkFormat = false } = options;
42+
43+
// Basic profile structure
44+
expect(profile.samples).toBeDefined();
45+
expect(profile.stacks).toBeDefined();
46+
expect(profile.frames).toBeDefined();
47+
expect(profile.thread_metadata).toBeDefined();
48+
49+
// SAMPLES
50+
expect(profile.samples.length).toBeGreaterThanOrEqual(2);
51+
let previousTimestamp: number = Number.NEGATIVE_INFINITY;
52+
53+
for (const sample of profile.samples) {
54+
expect(typeof sample.stack_id).toBe('number');
55+
expect(sample.stack_id).toBeGreaterThanOrEqual(0);
56+
expect(sample.stack_id).toBeLessThan(profile.stacks.length);
57+
58+
expect(sample.thread_id).toBe('0'); // Should be main thread
59+
60+
// Timestamp validation - differs between chunk format (v2) and legacy format
61+
if (isChunkFormat) {
62+
const chunkProfileSample = sample as ContinuousThreadCpuProfile['samples'][number];
63+
64+
// Chunk format uses numeric timestamps (UNIX timestamp in seconds with microseconds precision)
65+
expect(typeof chunkProfileSample.timestamp).toBe('number');
66+
const ts = chunkProfileSample.timestamp;
67+
expect(Number.isFinite(ts)).toBe(true);
68+
expect(ts).toBeGreaterThan(0);
69+
// Monotonic non-decreasing timestamps
70+
expect(ts).toBeGreaterThanOrEqual(previousTimestamp);
71+
previousTimestamp = ts;
72+
} else {
73+
// Legacy format uses elapsed_since_start_ns as a string
74+
const legacyProfileSample = sample as ThreadCpuProfile['samples'][number];
75+
76+
expect(typeof legacyProfileSample.elapsed_since_start_ns).toBe('string');
77+
expect(legacyProfileSample.elapsed_since_start_ns).toMatch(/^\d+$/); // Numeric string
78+
expect(parseInt(legacyProfileSample.elapsed_since_start_ns, 10)).toBeGreaterThanOrEqual(0);
79+
}
80+
}
81+
82+
// STACKS
83+
expect(profile.stacks.length).toBeGreaterThan(0);
84+
for (const stack of profile.stacks) {
85+
expect(Array.isArray(stack)).toBe(true);
86+
for (const frameIndex of stack) {
87+
expect(typeof frameIndex).toBe('number');
88+
expect(frameIndex).toBeGreaterThanOrEqual(0);
89+
expect(frameIndex).toBeLessThan(profile.frames.length);
90+
}
91+
}
92+
93+
// Frames
94+
expect(profile.frames.length).toBeGreaterThan(0);
95+
for (const frame of profile.frames) {
96+
expect(frame).toHaveProperty('function');
97+
expect(typeof frame.function).toBe('string');
98+
99+
// Some browser functions (fetch, setTimeout) may not have file locations
100+
if (frame.function !== 'fetch' && frame.function !== 'setTimeout') {
101+
expect(frame).toHaveProperty('abs_path');
102+
expect(frame).toHaveProperty('lineno');
103+
expect(frame).toHaveProperty('colno');
104+
expect(typeof frame.abs_path).toBe('string');
105+
expect(typeof frame.lineno).toBe('number');
106+
expect(typeof frame.colno).toBe('number');
107+
}
108+
}
109+
110+
// Function names validation (only when not minified and expected names provided)
111+
if (expectedFunctionNames && expectedFunctionNames.length > 0) {
112+
const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== '');
113+
114+
if ((process.env.PW_BUNDLE || '').endsWith('min')) {
115+
// In minified bundles, just check that we have some non-empty function names
116+
expect(functionNames.length).toBeGreaterThan(0);
117+
expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true);
118+
} else {
119+
// In non-minified bundles, check for expected function names
120+
expect(functionNames).toEqual(expect.arrayContaining(expectedFunctionNames));
121+
}
122+
}
123+
124+
// THREAD METADATA
125+
expect(profile.thread_metadata).toHaveProperty('0');
126+
expect(profile.thread_metadata['0']).toHaveProperty('name');
127+
expect(profile.thread_metadata['0'].name).toBe('main');
128+
129+
// DURATION
130+
if (minSampleDurationMs !== undefined) {
131+
let durationMs: number;
132+
133+
if (isChunkFormat) {
134+
// Chunk format: timestamps are in seconds
135+
const chunkProfile = profile as ContinuousThreadCpuProfile;
136+
137+
const startTimeSec = chunkProfile.samples[0].timestamp;
138+
const endTimeSec = chunkProfile.samples[chunkProfile.samples.length - 1].timestamp;
139+
durationMs = (endTimeSec - startTimeSec) * 1000; // Convert to ms
140+
} else {
141+
// Legacy format: elapsed_since_start_ns is in nanoseconds
142+
const legacyProfile = profile as ThreadCpuProfile;
143+
144+
const startTimeNs = parseInt(legacyProfile.samples[0].elapsed_since_start_ns, 10);
145+
const endTimeNs = parseInt(legacyProfile.samples[legacyProfile.samples.length - 1].elapsed_since_start_ns, 10);
146+
durationMs = (endTimeNs - startTimeNs) / 1_000_000; // Convert ns to ms
147+
}
148+
149+
expect(durationMs).toBeGreaterThan(minSampleDurationMs);
150+
}
151+
}

0 commit comments

Comments
 (0)