Skip to content

Commit 6ad8fc4

Browse files
authored
Mark file sync spans run in the main isolate with blocked_main_thread (#3270)
1 parent 68313a9 commit 6ad8fc4

16 files changed

+881
-27
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Mark file sync spans run in the main isolate with `blocked_main_thread` ([#3270](https://github.com/getsentry/sentry-dart/pull/3270))
8+
- This allows Sentry to create issues automatically out of file spans running a certain time on the main thread: https://docs.sentry.io/product/issues/issue-details/performance-issues/file-main-thread-io/
9+
310
## 9.7.0
411

512

packages/dart/lib/src/protocol/sentry_span.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ class SentrySpan extends ISentrySpan {
7777
}
7878
}
7979

80+
// Dispatch OnSpanFinish lifecycle event
81+
final callback =
82+
_hub.options.lifecycleRegistry.dispatchCallback(OnSpanFinish(this));
83+
if (callback is Future) {
84+
await callback;
85+
}
86+
8087
// The finished flag depends on the _endTimestamp
8188
// If we set this earlier then finished is true and then we cannot use setData etc...
8289
_endTimestamp = endTimestamp;

packages/dart/lib/src/sdk_lifecycle_hooks.dart

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,17 @@ class SdkLifecycleRegistry {
3434
callbacks?.remove(callback);
3535
}
3636

37-
FutureOr<void> dispatchCallback<T extends SdkLifecycleEvent>(T event) async {
38-
final callbacks = _lifecycleCallbacks[event.runtimeType] ?? [];
37+
FutureOr<void> dispatchCallback<T extends SdkLifecycleEvent>(T event) {
38+
final callbacks = _lifecycleCallbacks[event.runtimeType];
39+
if (callbacks == null || callbacks.isEmpty) {
40+
// Return synchronously when there are no callbacks to avoid unnecessary async boundary
41+
return null;
42+
}
43+
return _dispatchCallbackAsync(event, callbacks);
44+
}
45+
46+
Future<void> _dispatchCallbackAsync<T extends SdkLifecycleEvent>(
47+
T event, List<Function> callbacks) async {
3948
for (final cb in callbacks) {
4049
try {
4150
final result = (cb as SdkLifecycleCallback<T>)(event);
@@ -79,3 +88,11 @@ class OnSpanStart extends SdkLifecycleEvent {
7988

8089
final ISentrySpan span;
8190
}
91+
92+
/// Dispatched when a sampled span is finished.
93+
@internal
94+
class OnSpanFinish extends SdkLifecycleEvent {
95+
OnSpanFinish(this.span);
96+
97+
final ISentrySpan span;
98+
}

packages/dart/lib/src/span_data_convention.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class SpanDataConvention {
1010
// https://develop.sentry.dev/sdk/telemetry/traces/span-data-conventions/#thread
1111
static const threadId = 'thread.id';
1212
static const threadName = 'thread.name';
13+
static const blockedMainThread = 'blocked_main_thread';
1314

1415
// TODO: eventually add other data keys here as well
1516
}

packages/file/lib/src/sentry_file.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ class SentryFile implements File {
489489

490490
span?.origin = SentryTraceOrigins.autoFile;
491491
span?.setData('file.async', false);
492+
span?.setData('sync', true);
492493

493494
final Map<String, dynamic> breadcrumbData = {};
494495
breadcrumbData['file.async'] = false;

packages/file/test/sentry_file_test.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ void main() {
2828
expect(span.context.operation, 'file.copy');
2929
expect(span.data['file.size'], 7);
3030
expect(span.data['file.async'], async);
31+
if (!async) {
32+
expect(span.data['sync'], true);
33+
}
3134
expect(span.context.description, 'testfile.txt');
3235
expect(
3336
(span.data['file.path'] as String)
@@ -115,6 +118,9 @@ void main() {
115118
expect(span.context.operation, 'file.write');
116119
expect(span.data['file.size'], size);
117120
expect(span.data['file.async'], async);
121+
if (!async) {
122+
expect(span.data['sync'], true);
123+
}
118124
expect(span.context.description, 'testfile_create.txt');
119125
expect(
120126
(span.data['file.path'] as String)
@@ -200,6 +206,9 @@ void main() {
200206
expect(span.context.operation, 'file.delete');
201207
expect(span.data['file.size'], size);
202208
expect(span.data['file.async'], async);
209+
if (!async) {
210+
expect(span.data['sync'], true);
211+
}
203212
expect(span.context.description, 'testfile_delete.txt');
204213
expect(
205214
(span.data['file.path'] as String)
@@ -341,6 +350,9 @@ void main() {
341350
expect(span.context.operation, 'file.read');
342351
expect(span.data['file.size'], size);
343352
expect(span.data['file.async'], async);
353+
if (!async) {
354+
expect(span.data['sync'], true);
355+
}
344356
expect(span.context.description, fileName);
345357
expect(
346358
(span.data['file.path'] as String)
@@ -492,6 +504,9 @@ void main() {
492504
expect(span.context.operation, 'file.rename');
493505
expect(span.data['file.size'], 0);
494506
expect(span.data['file.async'], async);
507+
if (!async) {
508+
expect(span.data['sync'], true);
509+
}
495510
expect(span.context.description, name);
496511
expect(
497512
(span.data['file.path'] as String).endsWith('test_resources/$name'),
@@ -580,6 +595,9 @@ void main() {
580595
final span = call.transaction.spans.first;
581596

582597
expect(span.data['file.async'], async);
598+
if (!async) {
599+
expect(span.data['sync'], true);
600+
}
583601
expect(span.data['file.path'], null);
584602
expect(span.origin, SentryTraceOrigins.autoFile);
585603
}

packages/flutter/lib/src/integrations/thread_info_integration.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import 'package:meta/meta.dart';
44

55
import '../../sentry_flutter.dart';
6+
// ignore: implementation_imports
67
import '../isolate/isolate_helper.dart';
78

89
/// Integration for adding thread information to spans.
@@ -33,6 +34,8 @@ class ThreadInfoIntegration implements Integration<SentryFlutterOptions> {
3334

3435
options.lifecycleRegistry
3536
.registerCallback<OnSpanStart>(_addThreadInfoToSpan);
37+
options.lifecycleRegistry
38+
.registerCallback<OnSpanFinish>(_processSyncSpanOnFinish);
3639

3740
options.sdk.addIntegration(integrationName);
3841
}
@@ -41,6 +44,8 @@ class ThreadInfoIntegration implements Integration<SentryFlutterOptions> {
4144
void close() {
4245
_hub?.options.lifecycleRegistry
4346
.removeCallback<OnSpanStart>(_addThreadInfoToSpan);
47+
_hub?.options.lifecycleRegistry
48+
.removeCallback<OnSpanFinish>(_processSyncSpanOnFinish);
4449
}
4550

4651
Future<void> _addThreadInfoToSpan(OnSpanStart event) async {
@@ -65,4 +70,25 @@ class ThreadInfoIntegration implements Integration<SentryFlutterOptions> {
6570
span.setData(SpanDataConvention.threadName, threadName);
6671
}
6772
}
73+
74+
void _processSyncSpanOnFinish(OnSpanFinish event) {
75+
final span = event.span;
76+
if (span is! SentrySpan) {
77+
return;
78+
}
79+
80+
final data = span.data;
81+
82+
// Check if this is a sync operation
83+
if (data.containsKey('sync')) {
84+
// Check if we're on the main isolate by looking at thread name
85+
if (data['sync'] == true &&
86+
data[SpanDataConvention.threadName] == 'main') {
87+
span.setData(SpanDataConvention.blockedMainThread, true);
88+
}
89+
90+
// Always remove the sync flag
91+
span.removeData('sync');
92+
}
93+
}
6894
}

packages/flutter/test/integrations/thread_info_integration_test.dart

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,122 @@ void main() {
171171
expect(span.setDataCalls, isEmpty);
172172
});
173173
});
174+
175+
group('OnSpanFinish sync processing', () {
176+
test('sets blocked_main_thread when sync span finishes on main isolate',
177+
() async {
178+
fixture.mockHelper.setIsRootIsolate(true);
179+
180+
final hub = fixture.createHub();
181+
final integration = fixture.getSut();
182+
final span = fixture.createMockSpanWithData({
183+
'sync': true,
184+
SpanDataConvention.threadName: 'main',
185+
});
186+
187+
integration.call(hub, fixture.options);
188+
// ignore: invalid_use_of_internal_member
189+
await fixture.options.lifecycleRegistry
190+
.dispatchCallback(OnSpanFinish(span));
191+
192+
final setDataCalls = span.setDataCalls;
193+
expect(setDataCalls.length, equals(1));
194+
195+
final blockedMainThreadCall = setDataCalls.firstWhere(
196+
(call) => call.key == SpanDataConvention.blockedMainThread);
197+
expect(blockedMainThreadCall.value, equals(true));
198+
199+
// Check that sync was removed
200+
expect(span.removeDataCalls.length, equals(1));
201+
expect(span.removeDataCalls.first.key, equals('sync'));
202+
});
203+
204+
test(
205+
'does not set blocked_main_thread when sync span finishes on background isolate',
206+
() async {
207+
final hub = fixture.createHub();
208+
final integration = fixture.getSut();
209+
final span = fixture.createMockSpanWithData({
210+
'sync': true,
211+
SpanDataConvention.threadName: 'worker-thread',
212+
});
213+
214+
integration.call(hub, fixture.options);
215+
// ignore: invalid_use_of_internal_member
216+
await fixture.options.lifecycleRegistry
217+
.dispatchCallback(OnSpanFinish(span));
218+
219+
// Should not set blocked_main_thread
220+
final blockedMainThreadCalls = span.setDataCalls
221+
.where((call) => call.key == SpanDataConvention.blockedMainThread);
222+
expect(blockedMainThreadCalls, isEmpty);
223+
224+
// But should still remove sync
225+
expect(span.removeDataCalls.length, equals(1));
226+
expect(span.removeDataCalls.first.key, equals('sync'));
227+
});
228+
229+
test('does not process spans without sync data', () async {
230+
final hub = fixture.createHub();
231+
final integration = fixture.getSut();
232+
final span = fixture.createMockSpanWithData({
233+
SpanDataConvention.threadName: 'main',
234+
});
235+
236+
integration.call(hub, fixture.options);
237+
// ignore: invalid_use_of_internal_member
238+
await fixture.options.lifecycleRegistry
239+
.dispatchCallback(OnSpanFinish(span));
240+
241+
// Should not add any data or remove anything
242+
expect(span.setDataCalls, isEmpty);
243+
expect(span.removeDataCalls, isEmpty);
244+
});
245+
246+
test('removes sync flag even when sync is false', () async {
247+
final hub = fixture.createHub();
248+
final integration = fixture.getSut();
249+
final span = fixture.createMockSpanWithData({
250+
'sync': false,
251+
SpanDataConvention.threadName: 'main',
252+
});
253+
254+
integration.call(hub, fixture.options);
255+
// ignore: invalid_use_of_internal_member
256+
await fixture.options.lifecycleRegistry
257+
.dispatchCallback(OnSpanFinish(span));
258+
259+
// Should not set blocked_main_thread (sync is false)
260+
final blockedMainThreadCalls = span.setDataCalls
261+
.where((call) => call.key == SpanDataConvention.blockedMainThread);
262+
expect(blockedMainThreadCalls, isEmpty);
263+
264+
// But should still remove sync
265+
expect(span.removeDataCalls.length, equals(1));
266+
expect(span.removeDataCalls.first.key, equals('sync'));
267+
});
268+
269+
test('does not set blocked_main_thread when sync span has no thread name',
270+
() async {
271+
final hub = fixture.createHub();
272+
final integration = fixture.getSut();
273+
final span = fixture.createMockSpanWithData({'sync': true});
274+
275+
integration.call(hub, fixture.options);
276+
// ignore: invalid_use_of_internal_member
277+
await fixture.options.lifecycleRegistry
278+
.dispatchCallback(OnSpanFinish(span));
279+
280+
// Should not set blocked_main_thread (no thread name)
281+
final blockedMainThreadCalls = span.setDataCalls
282+
.where((call) => call.key == SpanDataConvention.blockedMainThread);
283+
expect(blockedMainThreadCalls, isEmpty);
284+
285+
// But should still remove sync
286+
expect(span.removeDataCalls.length, equals(1));
287+
expect(span.removeDataCalls.first.key, equals('sync'));
288+
});
289+
});
174290
}
175291

176292
class _Fixture {
@@ -194,6 +310,10 @@ class _Fixture {
194310
return _MockSpan();
195311
}
196312

313+
_MockSpan createMockSpanWithData(Map<String, dynamic> data) {
314+
return _MockSpan.withData(data);
315+
}
316+
197317
MockHub createHub() {
198318
final hub = MockHub();
199319
when(hub.options).thenReturn(options);
@@ -218,13 +338,31 @@ class _MockIsolateHelper extends Mock implements IsolateHelper {
218338
class _MockSpan extends Mock implements SentrySpan {
219339
final SentrySpanContext _context = SentrySpanContext(operation: 'test');
220340
final List<_SetDataCall> setDataCalls = [];
341+
final List<_RemoveDataCall> removeDataCalls = [];
342+
final Map<String, dynamic> _data = {};
343+
344+
_MockSpan();
345+
346+
_MockSpan.withData(Map<String, dynamic> data) {
347+
_data.addAll(data);
348+
}
221349

222350
@override
223351
SentrySpanContext get context => _context;
224352

353+
@override
354+
Map<String, dynamic> get data => _data;
355+
225356
@override
226357
void setData(String key, dynamic value) {
227358
setDataCalls.add(_SetDataCall(key, value));
359+
_data[key] = value;
360+
}
361+
362+
@override
363+
void removeData(String key) {
364+
removeDataCalls.add(_RemoveDataCall(key));
365+
_data.remove(key);
228366
}
229367
}
230368

@@ -234,3 +372,9 @@ class _SetDataCall {
234372

235373
_SetDataCall(this.key, this.value);
236374
}
375+
376+
class _RemoveDataCall {
377+
final String key;
378+
379+
_RemoveDataCall(this.key);
380+
}

0 commit comments

Comments
 (0)