Skip to content

Commit e3d6355

Browse files
committed
feat: Use full flamegraph for metrickit app hangs
1 parent d064999 commit e3d6355

File tree

6 files changed

+300
-22
lines changed

6 files changed

+300
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Add package traits for UI framework opt-out (#7578).
88
When building from source with Swift 6.1+ (using `Package@swift-6.1.swift`), you can enable the `NoUIFramework` trait to avoid linking UIKit or AppKit. Use this for command-line tools, headless server contexts, or other environments where UI frameworks are unavailable.
99
In Xcode 26.4 and later, add the Sentry package as a dependency and the `SentrySPM` product, then enable the `NoUIFramework` trait on the package reference (Package Dependencies → select Sentry → Traits).
10+
- Metric kit app hangs now report a full flamegraph rather than just one stacktrace during the hang. (#7185)
1011

1112
### Fixes
1213

Sources/Sentry/Public/SentryFrame.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ NS_SWIFT_NAME(Frame)
7070
*/
7171
@property (nonatomic, copy) NSString *_Nullable contextLine;
7272

73+
/**
74+
* Index of the parent frame used for flamegraphs.
75+
*/
76+
@property (nonatomic, copy) NSNumber *_Nullable parentIndex;
77+
78+
@property (nonatomic, copy) NSNumber *_Nullable sampleCount;
79+
7380
/**
7481
* Source code lines before the error location (up to 5 lines).
7582
* Mostly used for Godot errors.

Sources/Sentry/SentryFrame.m

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ - (instancetype)init
2828
[serializedData setValue:self.platform forKey:@"platform"];
2929
[serializedData setValue:self.contextLine forKey:@"context_line"];
3030
[serializedData setValue:self.preContext forKey:@"pre_context"];
31+
[serializedData setValue:self.parentIndex forKey:@"parent_index"];
32+
[serializedData setValue:self.sampleCount forKey:@"sample_count"];
3133
[serializedData setValue:self.postContext forKey:@"post_context"];
3234
[serializedData setValue:sentry_sanitize(self.vars) forKey:@"vars"];
3335
[SentryDictionary setBoolValue:self.inApp forKey:@"in_app" intoDictionary:serializedData];

Sources/Swift/Core/MetricKit/SentryMXCallStackTree+Parsing.swift

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,7 @@ extension SentryMXCallStackTree {
88
frame.toDebugMeta()
99
}.unique { $0.debugID }
1010
}
11-
12-
func prepare(event: Event, inAppLogic: SentryInAppLogic?, handled: Bool) {
13-
let debugMeta = toDebugMeta()
14-
let threads = sentryMXBacktrace(inAppLogic: inAppLogic, handled: handled)
15-
// First look for the crashing thread, but for events that were not a crash (like a hang) take the first thread
16-
// since those events only report one thread
17-
let exceptionThread = threads.first { $0.crashed?.boolValue == true } ?? threads.first
18-
event.debugMeta = debugMeta
19-
event.threads = threads
20-
21-
if let exceptionThread, let exception = event.exceptions?[0] {
22-
exception.stacktrace = exceptionThread.stacktrace
23-
exception.threadId = exceptionThread.threadId
24-
}
25-
}
26-
11+
2712
// A MetricKit CallStackTree is a flamegraph, but many Sentry APIs only support
2813
// a thread backtrace. A flamegraph is just a collection of many thread backtraces
2914
// generated by taking multiple samples. For example a hang from metric kit will
@@ -66,6 +51,40 @@ extension SentryMXCallStackTree {
6651
return thread
6752
}
6853
}
54+
55+
/// Flattens the call stack tree into a single thread with all frames.
56+
/// Each frame includes metadata in its `vars` field to allow reconstructing the original tree:
57+
/// - `parent_frame_index`: The index of the parent frame in the flat list (-1 for root frames)
58+
/// - `sample_count`: The number of samples at this frame
59+
///
60+
/// This preserves all sample data from the flamegraph rather than just the most common stack.
61+
func flattenedBacktrace(inAppLogic: SentryInAppLogic?, handled: Bool) -> [SentryThread] {
62+
callStacks.enumerated().map { index, callStack in
63+
let thread = SentryThread(threadId: NSNumber(value: index))
64+
var frames: [Frame] = []
65+
66+
// Traverse the tree and flatten all frames with parent references
67+
for rootFrame in callStack.callStackRootFrames {
68+
flattenFrame(rootFrame, parentIndex: -1, frames: &frames, inAppLogic: inAppLogic)
69+
}
70+
71+
thread.stacktrace = SentryStacktrace(frames: frames, registers: [:])
72+
thread.crashed = NSNumber(value: (callStack.threadAttributed ?? false) && !handled)
73+
return thread
74+
}
75+
}
76+
77+
private func flattenFrame(_ mxFrame: SentryMXFrame, parentIndex: Int, frames: inout [Frame], inAppLogic: SentryInAppLogic?) {
78+
let currentIndex = frames.count
79+
let frame = mxFrame.toSentryFrameWithTreeData(frameIndex: currentIndex, parentFrameIndex: parentIndex)
80+
frame.inApp = NSNumber(value: inAppLogic?.is(inApp: frame.package) ?? false)
81+
frames.append(frame)
82+
83+
// Recursively process child frames
84+
for subFrame in mxFrame.subFrames ?? [] {
85+
flattenFrame(subFrame, parentIndex: currentIndex, frames: &frames, inAppLogic: inAppLogic)
86+
}
87+
}
6988
}
7089

7190
extension SentryMXCallStack {
@@ -87,7 +106,7 @@ extension SentryMXFrame {
87106
}
88107
return [result] + (subFrames?.flatMap { $0.toDebugMeta() } ?? [])
89108
}
90-
109+
91110
func toSamples() -> [MXSample] {
92111
let selfFrame = MXSample.MXFrame(binaryUUID: binaryUUID, offsetIntoBinaryTextSegment: offsetIntoBinaryTextSegment, binaryName: binaryName, address: address)
93112
let subframes = subFrames ?? []
@@ -100,6 +119,22 @@ extension SentryMXFrame {
100119
}
101120
return result
102121
}
122+
123+
/// Converts this frame to a SentryFrame with tree metadata in the `vars` field.
124+
/// The metadata allows reconstructing the original tree structure from a flat list.
125+
func toSentryFrameWithTreeData(frameIndex: Int, parentFrameIndex: Int) -> Frame {
126+
let frame = Frame()
127+
frame.package = binaryName
128+
frame.instructionAddress = sentry_formatHexAddressUInt64Swift(address)
129+
if offsetIntoBinaryTextSegment >= 0 && offsetIntoBinaryTextSegment < address {
130+
frame.imageAddress = sentry_formatHexAddressUInt64Swift(address - UInt64(offsetIntoBinaryTextSegment))
131+
}
132+
133+
frame.parentIndex = parentFrameIndex as NSNumber
134+
frame.sampleCount = sampleCount as NSNumber?
135+
136+
return frame
137+
}
103138
}
104139

105140
private extension MXSample.MXFrame {

Sources/Swift/Core/MetricKit/SentryMXManager.swift

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,12 @@ final class SentryMXManager: NSObject, MXMetricManagerSubscriber {
8080
payload.hangDiagnostics?.forEach { diagnostic in
8181
let hangDuration = measurementFormatter.string(from: diagnostic.hangDuration)
8282
let exceptionValue = "MXHangDiagnostic hangDuration:\(hangDuration)"
83-
captureEvent(handled: true, exceptionValue: exceptionValue, exceptionType: "MXHangDiagnostic", exceptionMechanism: hangDiagnosticMechanism, timeStampBegin: payload.timeStampBegin, diagnostic: diagnostic)
83+
captureEvent(handled: true, exceptionValue: exceptionValue, exceptionType: "MXHangDiagnostic", exceptionMechanism: hangDiagnosticMechanism, timeStampBegin: payload.timeStampBegin, diagnostic: diagnostic, useFullCallStackTree: true)
8484
}
8585
}
8686
}
8787

88-
func captureEvent(handled: Bool, exceptionValue: String, exceptionType: String, exceptionMechanism: String, timeStampBegin: Date, diagnostic: MXDiagnostic & CallStackTreeProviding) {
88+
func captureEvent(handled: Bool, exceptionValue: String, exceptionType: String, exceptionMechanism: String, timeStampBegin: Date, diagnostic: MXDiagnostic & CallStackTreeProviding, useFullCallStackTree: Bool = false) {
8989
if let callStackTree = try? SentryMXCallStackTree.from(data: diagnostic.callStackTree.jsonRepresentation()) {
9090
let event = Event(level: handled ? .warning : .error)
9191
event.timestamp = timeStampBegin
@@ -95,12 +95,29 @@ final class SentryMXManager: NSObject, MXMetricManagerSubscriber {
9595
mechanism.synthetic = true
9696
exception.mechanism = mechanism
9797
event.exceptions = [exception]
98-
capture(event: event, handled: handled, callStackTree: callStackTree, diagnosticJSON: diagnostic.jsonRepresentation())
98+
capture(event: event, handled: handled, callStackTree: callStackTree, diagnosticJSON: diagnostic.jsonRepresentation(), useFullCallStackTree: useFullCallStackTree)
9999
}
100100
}
101101

102-
func capture(event: Event, handled: Bool, callStackTree: SentryMXCallStackTree, diagnosticJSON: Data) {
103-
callStackTree.prepare(event: event, inAppLogic: inAppLogic, handled: handled)
102+
func capture(event: Event, handled: Bool, callStackTree: SentryMXCallStackTree, diagnosticJSON: Data, useFullCallStackTree: Bool = false) {
103+
let debugMeta = callStackTree.toDebugMeta()
104+
let threads: [SentryThread]
105+
if useFullCallStackTree {
106+
// For hang diagnostics, use the flattened tree to preserve all samples
107+
threads = callStackTree.flattenedBacktrace(inAppLogic: inAppLogic, handled: handled)
108+
} else {
109+
threads = callStackTree.sentryMXBacktrace(inAppLogic: inAppLogic, handled: handled)
110+
}
111+
// First look for the crashing thread, but for events that were not a crash (like a hang) take the first thread
112+
// since those events only report one thread
113+
let exceptionThread = threads.first { $0.crashed?.boolValue == true } ?? threads.first
114+
event.debugMeta = debugMeta
115+
event.threads = threads
116+
117+
if let exceptionThread, let exception = event.exceptions?[0] {
118+
exception.stacktrace = exceptionThread.stacktrace
119+
exception.threadId = exceptionThread.threadId
120+
}
104121
// The crash event can be way from the past. We don't want to impact the current session.
105122
// Therefore we don't call captureFatalEvent.
106123
capture(event: event, diagnosticJSON: diagnosticJSON)

0 commit comments

Comments
 (0)