Skip to content

Commit 3bc6908

Browse files
authored
feat: Add continuous profiling support (Sample Format V2) (#1202)
## Summary Add support for continuous profiling (Profiling V2) while maintaining backward compatibility with transaction-based profiling (V1). ## Changes - Add `SentryProfileV2ChunkEvent` type for `profile_chunk` envelope items - Process and merge V2 chunks by `profiler_id` - Link profiles to traces via `contexts.profile.profiler_id` - Unify V1 and V2 profiles in `profilesByTraceId` storage - Handle late-arriving chunks with re-grafting - Add test fixtures for manual UI testing ## Testing ```bash pnpm dev # In another terminal: cd packages/spotlight node _fixtures/send_to_sidecar.cjs _fixtures/continuous_profiling/ ``` Then open Spotlight UI, navigate to the trace, and verify: 1. The "Profile" tab appears in trace details 2. Profile spans are grafted into the trace tree <img width="2200" height="1744" alt="image" src="https://github.com/user-attachments/assets/c0eb7946-df8f-42cb-a240-b38736966a54" /> Closes #567
1 parent 6bc2c4a commit 3bc6908

File tree

28 files changed

+1356
-330
lines changed

28 files changed

+1356
-330
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@spotlightjs/spotlight": minor
3+
---
4+
5+
Add support for continuous profiling (Profiling V2)
6+
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Continuous Profiling Test Fixtures
2+
3+
This directory contains test fixtures for continuous profiling (Sample Format V2).
4+
5+
## Files
6+
7+
| File | Description |
8+
|------|-------------|
9+
| `profile_chunk_1.txt` | First profile chunk - app launch & Sentry setup samples |
10+
| `profile_chunk_2.txt` | Second profile chunk - view loading & network samples |
11+
| `transaction.txt` | Transaction linked to profiles via `contexts.profile.profiler_id` |
12+
13+
## Usage
14+
15+
Send all fixtures to the sidecar for manual testing:
16+
17+
```bash
18+
# From the spotlight package directory
19+
cd packages/spotlight
20+
21+
# Send all continuous profiling fixtures
22+
node _fixtures/send_to_sidecar.cjs _fixtures/continuous_profiling/
23+
```
24+
25+
Or send individual files:
26+
27+
```bash
28+
node _fixtures/send_to_sidecar.cjs _fixtures/continuous_profiling/profile_chunk_1.txt
29+
node _fixtures/send_to_sidecar.cjs _fixtures/continuous_profiling/profile_chunk_2.txt
30+
node _fixtures/send_to_sidecar.cjs _fixtures/continuous_profiling/transaction.txt
31+
```
32+
33+
## Linking
34+
35+
The profile chunks and transaction are linked via:
36+
37+
| Field | Value |
38+
|-------|-------|
39+
| `profiler_id` | `71bba98d90b545c39f2ae73f702d7ef4` |
40+
| `trace_id` | `f1e2d3c4b5a6978012345678901234ab` |
41+
| `thread.id` | `259` (main thread) |
42+
43+
## Profile Chunk Contents
44+
45+
### Chunk 1 - App Launch (timestamps ~1724777211.50 - 1724777211.56)
46+
47+
Call stack progression:
48+
- `_main`
49+
- `UIApplicationMain` -> `_main`
50+
- `-[AppDelegate application:didFinishLaunchingWithOptions:]` -> `UIApplicationMain` -> `_main`
51+
- `-[AppDelegate setupSentry]` -> ... (deepest)
52+
53+
### Chunk 2 - View Loading (timestamps ~1724777211.57 - 1724777211.63)
54+
55+
Call stack progression:
56+
- `-[ViewController viewDidLoad]`
57+
- `-[ViewController setupUI]` -> `viewDidLoad`
58+
- `-[ViewController loadData]` -> `setupUI` -> `viewDidLoad`
59+
- `-[NetworkManager fetchItems]` -> ... (deepest)
60+
61+
## Transaction Spans
62+
63+
The transaction includes the following spans:
64+
- `app.start.cold` - Cold App Start (60ms)
65+
- `ui.load` - ViewController.viewDidLoad (60ms)
66+
- `http.client` - GET /api/items (40ms)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{"event_id":"3e11a5c9831f4e49939c0a81944ea2cb","sent_at":"2024-08-27T15:00:11.503779Z","sdk":{"name":"sentry.cocoa","version":"8.36.0"}}
2+
{"type":"profile_chunk","length":1671}
3+
{"type":"profile_chunk","version":"2","profiler_id":"71bba98d90b545c39f2ae73f702d7ef4","chunk_id":"3e11a5c9831f4e49939c0a81944ea2cb","platform":"cocoa","release":"io.sentry.sample.iOS-Swift@8.36.0+1","environment":"simulator","client_sdk":{"name":"sentry.cocoa","version":"8.36.0"},"debug_meta":{"images":[{"debug_id":"5819FF25-01CB-3D32-B84F-0634B37D3BBC","image_addr":"0x00000001023a8000","type":"macho","image_size":16384,"code_file":"/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS17.2.simruntime/Contents/Resources/RuntimeRoot/usr/lib/libLogRedirect.dylib"}]},"profile":{"samples":[{"timestamp":1724770811.503,"stack_id":0,"thread_id":"259"},{"timestamp":1724770811.513,"stack_id":1,"thread_id":"259"},{"timestamp":1724770811.523,"stack_id":2,"thread_id":"259"},{"timestamp":1724770811.533,"stack_id":3,"thread_id":"259"},{"timestamp":1724770811.543,"stack_id":2,"thread_id":"259"},{"timestamp":1724770811.553,"stack_id":1,"thread_id":"259"},{"timestamp":1724770811.563,"stack_id":0,"thread_id":"259"}],"stacks":[[0],[1,0],[2,1,0],[3,2,1,0]],"frames":[{"instruction_addr":"0x000000010232d144","function":"_main","filename":"main.m","lineno":14,"in_app":true},{"instruction_addr":"0x000000010232d200","function":"UIApplicationMain","module":"UIKit","in_app":false},{"instruction_addr":"0x000000010232d300","function":"-[AppDelegate application:didFinishLaunchingWithOptions:]","filename":"AppDelegate.swift","lineno":15,"in_app":true},{"instruction_addr":"0x000000010232d400","function":"-[AppDelegate setupSentry]","filename":"AppDelegate.swift","lineno":25,"in_app":true}],"thread_metadata":{"259":{"name":"main","priority":31}}}}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{"event_id":"abc123def456789012345678901234cd","sent_at":"2024-08-27T15:00:11.603779Z","sdk":{"name":"sentry.cocoa","version":"8.36.0"}}
2+
{"type":"profile_chunk","length":1432}
3+
{"type":"profile_chunk","version":"2","profiler_id":"71bba98d90b545c39f2ae73f702d7ef4","chunk_id":"abc123def456789012345678901234cd","platform":"cocoa","release":"io.sentry.sample.iOS-Swift@8.36.0+1","environment":"simulator","client_sdk":{"name":"sentry.cocoa","version":"8.36.0"},"profile":{"samples":[{"timestamp":1724770811.573,"stack_id":0,"thread_id":"259"},{"timestamp":1724770811.583,"stack_id":1,"thread_id":"259"},{"timestamp":1724770811.593,"stack_id":2,"thread_id":"259"},{"timestamp":1724770811.603,"stack_id":3,"thread_id":"259"},{"timestamp":1724770811.613,"stack_id":2,"thread_id":"259"},{"timestamp":1724770811.623,"stack_id":1,"thread_id":"259"},{"timestamp":1724770811.633,"stack_id":0,"thread_id":"259"}],"stacks":[[0],[1,0],[2,1,0],[3,2,1,0]],"frames":[{"instruction_addr":"0x000000010232e100","function":"-[ViewController viewDidLoad]","filename":"ViewController.swift","lineno":20,"in_app":true},{"instruction_addr":"0x000000010232e200","function":"-[ViewController setupUI]","filename":"ViewController.swift","lineno":35,"in_app":true},{"instruction_addr":"0x000000010232e300","function":"-[ViewController loadData]","filename":"ViewController.swift","lineno":50,"in_app":true},{"instruction_addr":"0x000000010232e400","function":"-[NetworkManager fetchItems]","filename":"NetworkManager.swift","lineno":15,"in_app":true}],"thread_metadata":{"259":{"name":"main","priority":31}}}}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{"event_id":"txn_continuous_profile_123abc","sent_at":"2024-08-27T15:00:11.700000Z","trace":{"environment":"simulator","public_key":"abc123def456","trace_id":"f1e2d3c4b5a6978012345678901234ab"},"sdk":{"name":"sentry.cocoa","version":"8.36.0"}}
2+
{"type":"transaction","length":2262}
3+
{"type":"transaction","transaction":"iOS App Launch","transaction_info":{"source":"custom"},"contexts":{"trace":{"trace_id":"f1e2d3c4b5a6978012345678901234ab","span_id":"abc123def4567890","op":"app.launch","description":"iOS Application Launch","status":"ok","data":{"thread.id":"259","thread.name":"main"}},"profile":{"profiler_id":"71bba98d90b545c39f2ae73f702d7ef4"},"device":{"family":"iPhone","model":"iPhone14,2","arch":"arm64","simulator":true},"os":{"name":"iOS","version":"17.2"},"app":{"app_name":"iOS-Swift","app_version":"8.36.0","app_build":"1"}},"tags":{"environment":"simulator"},"timestamp":"2024-08-27T15:00:11.640000Z","start_timestamp":"2024-08-27T15:00:11.500000Z","spans":[{"trace_id":"f1e2d3c4b5a6978012345678901234ab","span_id":"span1234567890ab","parent_span_id":"abc123def4567890","op":"app.start.cold","description":"Cold App Start","start_timestamp":"2024-08-27T15:00:11.503779Z","timestamp":"2024-08-27T15:00:11.563779Z","status":"ok","data":{"thread.id":"259","thread.name":"main"}},{"trace_id":"f1e2d3c4b5a6978012345678901234ab","span_id":"span2345678901bc","parent_span_id":"abc123def4567890","op":"ui.load","description":"ViewController.viewDidLoad","start_timestamp":"2024-08-27T15:00:11.573779Z","timestamp":"2024-08-27T15:00:11.633779Z","status":"ok","data":{"thread.id":"259","thread.name":"main"}},{"trace_id":"f1e2d3c4b5a6978012345678901234ab","span_id":"span3456789012cd","parent_span_id":"span2345678901bc","op":"http.client","description":"GET /api/items","start_timestamp":"2024-08-27T15:00:11.583779Z","timestamp":"2024-08-27T15:00:11.623779Z","status":"ok","data":{"http.method":"GET","http.url":"https://api.example.com/api/items","http.status_code":200,"thread.id":"259"}}],"measurements":{"app_start_cold":{"value":140,"unit":"millisecond"},"frames_total":{"value":120,"unit":"none"},"frames_slow":{"value":2,"unit":"none"},"frames_frozen":{"value":0,"unit":"none"}},"event_id":"txn_continuous_profile_123abc","platform":"cocoa","release":"io.sentry.sample.iOS-Swift@8.36.0+1","environment":"simulator","sdk":{"name":"sentry.cocoa","version":"8.36.0","packages":[{"name":"cocoapods:sentry-cocoa","version":"8.36.0"}],"integrations":["AutoBreadcrumbTracking","AutoSessionTracking","Crash","Performance","Profiling"]}}

packages/spotlight/src/server/formatters/md/__tests__/test_envelopes.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,3 +455,216 @@ export const envelopeSecondTransactionEvent = {
455455
version: "9.42.1",
456456
},
457457
};
458+
459+
// V1 Profile Event (transaction-based profiling)
460+
export const envelopeProfileV1Event = {
461+
type: "profile" as const,
462+
version: "1" as const,
463+
event_id: "profile1234567890abcdef1234567890ab",
464+
timestamp: 1754524400.5,
465+
platform: "python",
466+
device: {
467+
architecture: "x86_64",
468+
is_emulator: false,
469+
locale: "en-US",
470+
manufacturer: "Apple",
471+
model: "MacBookPro18,3",
472+
},
473+
os: {
474+
name: "macOS",
475+
version: "14.0",
476+
build_number: "23A344",
477+
},
478+
transactions: [
479+
{
480+
name: "/api/users",
481+
id: "txn123456789abcdef",
482+
trace_id: "71a8c5e41ae1044dee67f50a07538fe7",
483+
active_thread_id: "1",
484+
relative_start_ns: "0",
485+
relative_end_ns: "100000000",
486+
},
487+
],
488+
profile: {
489+
samples: [
490+
{ elapsed_since_start_ns: "0", stack_id: 0, thread_id: "1" },
491+
{ elapsed_since_start_ns: "10000000", stack_id: 1, thread_id: "1" },
492+
{ elapsed_since_start_ns: "20000000", stack_id: 2, thread_id: "1" },
493+
{ elapsed_since_start_ns: "30000000", stack_id: 1, thread_id: "1" },
494+
{ elapsed_since_start_ns: "40000000", stack_id: 0, thread_id: "1" },
495+
],
496+
stacks: [
497+
[0], // main
498+
[1, 0], // handle_request -> main
499+
[2, 1, 0], // query_database -> handle_request -> main
500+
],
501+
frames: [
502+
{ function: "main", filename: "app.py", lineno: 10, in_app: true },
503+
{ function: "handle_request", filename: "views.py", lineno: 25, in_app: true },
504+
{ function: "query_database", filename: "db.py", lineno: 50, in_app: true },
505+
],
506+
thread_metadata: {
507+
"1": { name: "MainThread", priority: 5 },
508+
},
509+
},
510+
};
511+
512+
// V2 Profile Chunk Event (continuous profiling)
513+
export const envelopeProfileV2ChunkEvent = {
514+
type: "profile_chunk" as const,
515+
version: "2" as const,
516+
profiler_id: "71bba98d90b545c39f2ae73f702d7ef4",
517+
chunk_id: "3e11a5c9831f4e49939c0a81944ea2cb",
518+
platform: "cocoa",
519+
release: "io.sentry.sample.iOS-Swift@8.36.0+1",
520+
environment: "simulator",
521+
client_sdk: {
522+
name: "sentry.cocoa",
523+
version: "8.36.0",
524+
},
525+
debug_meta: {
526+
images: [
527+
{
528+
debug_id: "5819FF25-01CB-3D32-B84F-0634B37D3BBC",
529+
image_addr: "0x00000001023a8000",
530+
type: "macho",
531+
image_size: 16384,
532+
code_file:
533+
"/Library/Developer/CoreSimulator/Volumes/iOS_21C62/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.2.simruntime/Contents/Resources/RuntimeRoot/usr/lib/libLogRedirect.dylib",
534+
},
535+
],
536+
},
537+
measurements: {
538+
frozen_frame_renders: {
539+
unit: "nanosecond",
540+
values: [{ timestamp: 1724777211.6403089, value: 16000000 }],
541+
},
542+
},
543+
profile: {
544+
samples: [
545+
{ timestamp: 1724777211.5037799, stack_id: 0, thread_id: "259" },
546+
{ timestamp: 1724777211.5137799, stack_id: 1, thread_id: "259" },
547+
{ timestamp: 1724777211.5237799, stack_id: 2, thread_id: "259" },
548+
{ timestamp: 1724777211.5337799, stack_id: 1, thread_id: "259" },
549+
{ timestamp: 1724777211.5437799, stack_id: 0, thread_id: "259" },
550+
],
551+
stacks: [
552+
[0], // _main
553+
[1, 0], // UIApplicationMain -> _main
554+
[2, 1, 0], // -[AppDelegate application:didFinishLaunchingWithOptions:] -> UIApplicationMain -> _main
555+
],
556+
frames: [
557+
{
558+
instruction_addr: "0x000000010232d144",
559+
function: "_main",
560+
filename: "main.m",
561+
in_app: true,
562+
},
563+
{
564+
instruction_addr: "0x000000010232d200",
565+
function: "UIApplicationMain",
566+
module: "UIKit",
567+
in_app: false,
568+
},
569+
{
570+
instruction_addr: "0x000000010232d300",
571+
function: "-[AppDelegate application:didFinishLaunchingWithOptions:]",
572+
filename: "AppDelegate.swift",
573+
lineno: 15,
574+
in_app: true,
575+
},
576+
],
577+
thread_metadata: {
578+
"259": { name: "main", priority: 31 },
579+
},
580+
},
581+
};
582+
583+
// Second V2 chunk for the same profiler session (for testing chunk merging)
584+
export const envelopeProfileV2ChunkEvent2 = {
585+
type: "profile_chunk" as const,
586+
version: "2" as const,
587+
profiler_id: "71bba98d90b545c39f2ae73f702d7ef4", // Same profiler_id
588+
chunk_id: "abc123def456789012345678901234cd", // Different chunk_id
589+
platform: "cocoa",
590+
release: "io.sentry.sample.iOS-Swift@8.36.0+1",
591+
environment: "simulator",
592+
client_sdk: {
593+
name: "sentry.cocoa",
594+
version: "8.36.0",
595+
},
596+
profile: {
597+
samples: [
598+
{ timestamp: 1724777211.553, stack_id: 0, thread_id: "259" },
599+
{ timestamp: 1724777211.563, stack_id: 1, thread_id: "259" },
600+
{ timestamp: 1724777211.573, stack_id: 0, thread_id: "259" },
601+
],
602+
stacks: [
603+
[0], // viewDidLoad
604+
[1, 0], // loadData -> viewDidLoad
605+
],
606+
frames: [
607+
{
608+
instruction_addr: "0x000000010232e100",
609+
function: "-[ViewController viewDidLoad]",
610+
filename: "ViewController.swift",
611+
lineno: 20,
612+
in_app: true,
613+
},
614+
{
615+
instruction_addr: "0x000000010232e200",
616+
function: "-[ViewController loadData]",
617+
filename: "ViewController.swift",
618+
lineno: 45,
619+
in_app: true,
620+
},
621+
],
622+
thread_metadata: {
623+
"259": { name: "main", priority: 31 },
624+
},
625+
},
626+
};
627+
628+
// Transaction with profiler_id context (for V2 profile linking)
629+
export const envelopeTransactionWithProfilerContext = {
630+
event_id: "txn_with_profiler_123",
631+
type: "transaction",
632+
transaction: "/ios/app/launch",
633+
timestamp: 1724777211.6,
634+
start_timestamp: 1724777211.5,
635+
platform: "cocoa",
636+
contexts: {
637+
trace: {
638+
trace_id: "f1e2d3c4b5a6978012345678901234ab",
639+
span_id: "abc123def4567890",
640+
parent_span_id: undefined,
641+
data: {
642+
"thread.id": "259",
643+
"thread.name": "main",
644+
},
645+
},
646+
profile: {
647+
profiler_id: "71bba98d90b545c39f2ae73f702d7ef4", // Links to V2 profile chunks
648+
},
649+
},
650+
spans: [
651+
{
652+
span_id: "span1234567890ab",
653+
parent_span_id: "abc123def4567890",
654+
trace_id: "f1e2d3c4b5a6978012345678901234ab",
655+
op: "app.start",
656+
description: "Application Launch",
657+
start_timestamp: 1724777211.51,
658+
timestamp: 1724777211.55,
659+
duration: 40,
660+
status: "ok",
661+
data: {
662+
"thread.id": "259",
663+
},
664+
},
665+
],
666+
sdk: {
667+
name: "sentry.cocoa",
668+
version: "8.36.0",
669+
},
670+
};

packages/spotlight/src/server/parser/types.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,61 @@ export type SentryProfileV1Event = CommonEventAttrs & {
223223
profile: SentryProfile;
224224
};
225225

226+
// V2 Profile (Continuous Profiling) Types
227+
export type ProfileV2Sample = {
228+
timestamp: number; // Unix timestamp in seconds with microseconds precision
229+
stack_id: number;
230+
thread_id: string;
231+
};
232+
233+
export type ProfileV2MeasurementValue = {
234+
timestamp: number;
235+
value: number;
236+
};
237+
238+
export type ProfileV2Measurement = {
239+
unit: string;
240+
values: ProfileV2MeasurementValue[];
241+
};
242+
243+
export type SentryProfileV2 = {
244+
samples: ProfileV2Sample[];
245+
stacks: number[][];
246+
frames: EventFrame[];
247+
thread_metadata: Record<
248+
string,
249+
{
250+
name?: string;
251+
priority?: number;
252+
}
253+
>;
254+
};
255+
256+
export type SentryProfileV2ChunkEvent = {
257+
type: "profile_chunk";
258+
version: "2";
259+
profiler_id: string; // Links chunks from same profiler session
260+
chunk_id: string; // Unique ID for this chunk
261+
platform: string;
262+
release?: string;
263+
environment?: string;
264+
client_sdk?: {
265+
name: string;
266+
version: string;
267+
};
268+
debug_meta?: {
269+
images?: Array<{
270+
debug_id?: string;
271+
image_addr?: string;
272+
type?: string;
273+
image_size?: number;
274+
code_file?: string;
275+
}>;
276+
};
277+
measurements?: Record<string, ProfileV2Measurement>;
278+
profile: SentryProfileV2;
279+
};
280+
226281
export type SentryLogEventItem = SerializedLog & {
227282
id: string; // Need to have a unique id for each log
228283
severity_number: number;
@@ -234,4 +289,9 @@ export type SentryLogEvent = CommonEventAttrs & {
234289
items: Array<SentryLogEventItem>;
235290
};
236291

237-
export type SentryEvent = SentryErrorEvent | SentryTransactionEvent | SentryProfileV1Event | SentryLogEvent;
292+
export type SentryEvent =
293+
| SentryErrorEvent
294+
| SentryTransactionEvent
295+
| SentryProfileV1Event
296+
| SentryProfileV2ChunkEvent
297+
| SentryLogEvent;

packages/spotlight/src/ui/lib/isElectron.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,3 @@
44
* Uses typeof check to safely handle undefined without throwing.
55
*/
66
export const IS_ELECTRON = typeof __IS_ELECTRON__ !== "undefined" && __IS_ELECTRON__;
7-

0 commit comments

Comments
 (0)