Skip to content

Commit 99f0c60

Browse files
committed
Add thread (isoalte) info to sentry spans
1 parent 4481076 commit 99f0c60

File tree

6 files changed

+309
-0
lines changed

6 files changed

+309
-0
lines changed

dart/lib/src/span_data_convention.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,10 @@ class SpanDataConvention {
66
static const frozenFrames = 'frames.frozen';
77
static const framesDelay = 'frames.delay';
88

9+
// Thread/Isolate data keys according to Sentry span data conventions
10+
// https://develop.sentry.dev/sdk/telemetry/traces/span-data-conventions/#thread
11+
static const threadId = 'thread.id';
12+
static const threadName = 'thread.name';
13+
914
// TODO: eventually add other data keys here as well
1015
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import 'package:flutter/services.dart';
2+
import 'package:meta/meta.dart';
3+
// ignore: implementation_imports
4+
import 'package:sentry/src/utils/isolate_utils.dart' as isolate_utils;
5+
6+
@internal
7+
class IsolateHelper {
8+
// ignore: invalid_use_of_internal_member
9+
String? getIsolateName() => isolate_utils.getIsolateName();
10+
11+
bool isRootIsolate() {
12+
return ServicesBinding.rootIsolateToken != null;
13+
}
14+
}

flutter/lib/src/sentry_flutter.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import 'native/sentry_native_binding.dart';
3030
import 'profiling.dart';
3131
import 'replay/integration.dart';
3232
import 'screenshot/screenshot_support.dart';
33+
import 'thread_info_collector.dart';
3334
import 'utils/platform_dispatcher_wrapper.dart';
3435
import 'version.dart';
3536
import 'view_hierarchy/view_hierarchy_integration.dart';
@@ -146,6 +147,10 @@ mixin SentryFlutter {
146147
}
147148

148149
options.addEventProcessor(PlatformExceptionEventProcessor());
150+
151+
if (options.isTracingEnabled()) {
152+
options.addPerformanceCollector(ThreadInfoCollector());
153+
}
149154

150155
_setSdk(options);
151156
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'package:meta/meta.dart';
2+
import '../../sentry_flutter.dart';
3+
import 'isolate_helper.dart';
4+
5+
@internal
6+
class ThreadInfoCollector implements PerformanceContinuousCollector {
7+
final IsolateHelper _isolateHelper;
8+
9+
ThreadInfoCollector([IsolateHelper? isolateHelper])
10+
: _isolateHelper = isolateHelper ?? IsolateHelper();
11+
12+
@override
13+
Future<void> onSpanStarted(ISentrySpan span) async {
14+
// Check if we're in the root isolate first
15+
if (_isolateHelper.isRootIsolate()) {
16+
// For root isolate, always set thread name as "main"
17+
span.setData(SpanDataConvention.threadId, 'main'.hashCode.toString());
18+
span.setData(SpanDataConvention.threadName, 'main');
19+
return;
20+
}
21+
22+
// For non-root isolates, get thread info dynamically for each span to handle multi-isolate scenarios
23+
final isolateName = _isolateHelper.getIsolateName();
24+
25+
// Only set thread info if we have a valid isolate name
26+
if (isolateName != null && isolateName.isNotEmpty) {
27+
final threadName = isolateName;
28+
final threadId = isolateName.hashCode.toString();
29+
30+
span.setData(SpanDataConvention.threadId, threadId);
31+
span.setData(SpanDataConvention.threadName, threadName);
32+
}
33+
}
34+
35+
@override
36+
Future<void> onSpanFinished(ISentrySpan span, DateTime endTimestamp) async {
37+
// No-op: we only need to set data when span starts
38+
}
39+
40+
@override
41+
void clear() {
42+
// No-op: thread info doesn't change during execution
43+
}
44+
}

flutter/test/sentry_flutter_test.dart

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'package:sentry_flutter/src/replay/integration.dart';
1818
import 'package:sentry_flutter/src/version.dart';
1919
import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_integration.dart';
2020
import 'package:sentry_flutter/src/web/javascript_transport.dart';
21+
import 'package:sentry_flutter/src/thread_info_collector.dart';
2122

2223
import 'mocks.dart';
2324
import 'mocks.mocks.dart';
@@ -693,6 +694,54 @@ void main() {
693694
);
694695
SentryFlutter.native = null;
695696
});
697+
698+
test('ThreadInfoCollector is added when tracing is enabled', () async {
699+
final sentryFlutterOptions =
700+
defaultTestOptions(checker: MockRuntimeChecker())
701+
..platform = MockPlatform.android()
702+
..methodChannel = native.channel
703+
..tracesSampleRate = 1.0; // Enable tracing
704+
705+
SentryFlutter.native = mockNativeBinding();
706+
await SentryFlutter.init(
707+
(options) {
708+
expect(
709+
options.performanceCollectors
710+
.any((collector) => collector is ThreadInfoCollector),
711+
true,
712+
reason:
713+
'ThreadInfoCollector should be added when tracing is enabled',
714+
);
715+
},
716+
appRunner: appRunner,
717+
options: sentryFlutterOptions,
718+
);
719+
SentryFlutter.native = null;
720+
});
721+
722+
test('ThreadInfoCollector is not added when tracing is disabled', () async {
723+
final sentryFlutterOptions =
724+
defaultTestOptions(checker: MockRuntimeChecker())
725+
..platform = MockPlatform.android()
726+
..methodChannel = native.channel
727+
..tracesSampleRate = null; // Disable tracing
728+
729+
SentryFlutter.native = mockNativeBinding();
730+
await SentryFlutter.init(
731+
(options) {
732+
expect(
733+
options.performanceCollectors
734+
.any((collector) => collector is ThreadInfoCollector),
735+
false,
736+
reason:
737+
'ThreadInfoCollector should not be added when tracing is disabled',
738+
);
739+
},
740+
appRunner: appRunner,
741+
options: sentryFlutterOptions,
742+
);
743+
SentryFlutter.native = null;
744+
});
696745
});
697746

698747
test('resumeAppHangTracking calls native method when available', () async {
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
@TestOn('vm')
2+
library;
3+
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:mockito/mockito.dart';
6+
import 'package:sentry_flutter/src/thread_info_collector.dart';
7+
import 'package:sentry_flutter/src/isolate_helper.dart';
8+
import 'package:sentry/src/span_data_convention.dart';
9+
import 'package:sentry/src/protocol/sentry_span.dart';
10+
import 'package:sentry/src/sentry_span_context.dart';
11+
12+
void main() {
13+
late _Fixture fixture;
14+
15+
setUp(() {
16+
fixture = _Fixture();
17+
});
18+
19+
group('ThreadInfoCollector', () {
20+
test('sets main thread name when in root isolate', () async {
21+
fixture.mockHelper.setIsRootIsolate(true);
22+
fixture.mockHelper.setIsolateName("main(debug)");
23+
24+
final collector = fixture.getSut();
25+
final span = fixture.createMockSpan();
26+
27+
await collector.onSpanStarted(span);
28+
29+
final setDataCalls = span.setDataCalls;
30+
expect(setDataCalls.length, equals(2));
31+
32+
final threadIdCall = setDataCalls
33+
.firstWhere((call) => call.key == SpanDataConvention.threadId);
34+
final threadNameCall = setDataCalls
35+
.firstWhere((call) => call.key == SpanDataConvention.threadName);
36+
37+
expect(threadIdCall.value, equals('main'.hashCode.toString()));
38+
expect(threadNameCall.value, equals('main'));
39+
});
40+
41+
test('adds thread information when isolate has name', () async {
42+
fixture.mockHelper.setIsRootIsolate(false);
43+
fixture.mockHelper.setIsolateName('worker-thread');
44+
45+
final collector = fixture.getSut();
46+
final span = fixture.createMockSpan();
47+
48+
await collector.onSpanStarted(span);
49+
50+
final setDataCalls = span.setDataCalls;
51+
expect(setDataCalls.length, equals(2));
52+
53+
final threadIdCall = setDataCalls
54+
.firstWhere((call) => call.key == SpanDataConvention.threadId);
55+
final threadNameCall = setDataCalls
56+
.firstWhere((call) => call.key == SpanDataConvention.threadName);
57+
58+
expect(threadIdCall.value, equals('worker-thread'.hashCode.toString()));
59+
expect(threadNameCall.value, equals('worker-thread'));
60+
});
61+
62+
test('onSpanFinished is no-op', () async {
63+
fixture.mockHelper.setIsRootIsolate(false);
64+
final collector = fixture.getSut();
65+
final span = fixture.createMockSpan();
66+
67+
await collector.onSpanFinished(span, DateTime.now());
68+
expect(span.setDataCalls, isEmpty);
69+
});
70+
71+
test('gets thread info dynamically for each span', () async {
72+
fixture.mockHelper.setIsRootIsolate(false);
73+
fixture.mockHelper.setIsolateName('dynamic-test');
74+
75+
final collector = fixture.getSut();
76+
final span = fixture.createMockSpan();
77+
78+
await collector.onSpanStarted(span);
79+
final firstCallCount = span.setDataCalls.length;
80+
81+
final span2 = fixture.createMockSpan();
82+
await collector.onSpanStarted(span2);
83+
84+
// Should have same number of calls for both spans (thread info collected fresh each time)
85+
expect(span2.setDataCalls.length, equals(firstCallCount));
86+
87+
// Both spans should have thread info when isolate has a name
88+
expect(firstCallCount, equals(2));
89+
});
90+
91+
test('uses provided isolate name correctly', () async {
92+
fixture.mockHelper.setIsRootIsolate(false);
93+
fixture.mockHelper.setIsolateName('custom-isolate-name');
94+
final collector = fixture.getSut();
95+
final span = fixture.createMockSpan();
96+
97+
await collector.onSpanStarted(span);
98+
99+
// Find thread data calls
100+
String? threadId;
101+
String? threadName;
102+
for (final call in span.setDataCalls) {
103+
if (call.key == SpanDataConvention.threadId) {
104+
threadId = call.value as String?;
105+
}
106+
if (call.key == SpanDataConvention.threadName) {
107+
threadName = call.value as String?;
108+
}
109+
}
110+
111+
expect(threadName, equals('custom-isolate-name'));
112+
expect(threadId, equals('custom-isolate-name'.hashCode.toString()));
113+
});
114+
115+
test('no thread info when isolate name is null', () async {
116+
fixture.mockHelper.setIsRootIsolate(false);
117+
fixture.mockHelper.setIsolateName(null);
118+
final collector = fixture.getSut();
119+
final span = fixture.createMockSpan();
120+
121+
await collector.onSpanStarted(span);
122+
123+
// When isolate name is null, no thread data should be set
124+
expect(span.setDataCalls, isEmpty);
125+
});
126+
127+
test('no thread info when isolate name is empty', () async {
128+
fixture.mockHelper.setIsRootIsolate(false);
129+
fixture.mockHelper.setIsolateName('');
130+
final collector = fixture.getSut();
131+
final span = fixture.createMockSpan();
132+
133+
await collector.onSpanStarted(span);
134+
135+
// When isolate name is empty, no thread data should be set
136+
expect(span.setDataCalls, isEmpty);
137+
});
138+
});
139+
}
140+
141+
class _Fixture {
142+
late _MockIsolateHelper mockHelper;
143+
144+
_Fixture() {
145+
mockHelper = _MockIsolateHelper();
146+
// Set default return values to avoid null errors
147+
mockHelper.setIsRootIsolate(false);
148+
mockHelper.setIsolateName(null);
149+
}
150+
151+
ThreadInfoCollector getSut() {
152+
return ThreadInfoCollector(mockHelper);
153+
}
154+
155+
_MockSpan createMockSpan() {
156+
return _MockSpan();
157+
}
158+
}
159+
160+
class _MockIsolateHelper extends Mock implements IsolateHelper {
161+
bool _isRootIsolate = false;
162+
String? _isolateName;
163+
164+
@override
165+
bool isRootIsolate() => _isRootIsolate;
166+
167+
@override
168+
String? getIsolateName() => _isolateName;
169+
170+
void setIsRootIsolate(bool value) => _isRootIsolate = value;
171+
void setIsolateName(String? value) => _isolateName = value;
172+
}
173+
174+
class _MockSpan extends Mock implements SentrySpan {
175+
final SentrySpanContext _context = SentrySpanContext(operation: 'test');
176+
final List<_SetDataCall> setDataCalls = [];
177+
178+
@override
179+
SentrySpanContext get context => _context;
180+
181+
@override
182+
void setData(String key, dynamic value) {
183+
setDataCalls.add(_SetDataCall(key, value));
184+
}
185+
}
186+
187+
class _SetDataCall {
188+
final String key;
189+
final dynamic value;
190+
191+
_SetDataCall(this.key, this.value);
192+
}

0 commit comments

Comments
 (0)