Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions dart/lib/src/span_data_convention.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@ class SpanDataConvention {
static const frozenFrames = 'frames.frozen';
static const framesDelay = 'frames.delay';

// Thread/Isolate data keys according to Sentry span data conventions
// https://develop.sentry.dev/sdk/telemetry/traces/span-data-conventions/#thread
static const threadId = 'thread.id';
static const threadName = 'thread.name';

// TODO: eventually add other data keys here as well
}
14 changes: 14 additions & 0 deletions flutter/lib/src/isolate_helper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:flutter/services.dart';
import 'package:meta/meta.dart';
// ignore: implementation_imports
import 'package:sentry/src/utils/isolate_utils.dart' as isolate_utils;

@internal
class IsolateHelper {
// ignore: invalid_use_of_internal_member
String? getIsolateName() => isolate_utils.getIsolateName();

bool isRootIsolate() {
return ServicesBinding.rootIsolateToken != null;
}
}
5 changes: 5 additions & 0 deletions flutter/lib/src/sentry_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import 'native/sentry_native_binding.dart';
import 'profiling.dart';
import 'replay/integration.dart';
import 'screenshot/screenshot_support.dart';
import 'thread_info_collector.dart';
import 'utils/platform_dispatcher_wrapper.dart';
import 'version.dart';
import 'view_hierarchy/view_hierarchy_integration.dart';
Expand Down Expand Up @@ -146,6 +147,10 @@ mixin SentryFlutter {
}

options.addEventProcessor(PlatformExceptionEventProcessor());

if (options.isTracingEnabled()) {
options.addPerformanceCollector(ThreadInfoCollector());
}

_setSdk(options);
}
Expand Down
44 changes: 44 additions & 0 deletions flutter/lib/src/thread_info_collector.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'package:meta/meta.dart';
import '../../sentry_flutter.dart';
import 'isolate_helper.dart';

@internal
class ThreadInfoCollector implements PerformanceContinuousCollector {
final IsolateHelper _isolateHelper;

ThreadInfoCollector([IsolateHelper? isolateHelper])
: _isolateHelper = isolateHelper ?? IsolateHelper();

@override
Future<void> onSpanStarted(ISentrySpan span) async {
// Check if we're in the root isolate first
if (_isolateHelper.isRootIsolate()) {
// For root isolate, always set thread name as "main"
span.setData(SpanDataConvention.threadId, 'main'.hashCode.toString());
span.setData(SpanDataConvention.threadName, 'main');
return;
}

// For non-root isolates, get thread info dynamically for each span to handle multi-isolate scenarios
final isolateName = _isolateHelper.getIsolateName();

// Only set thread info if we have a valid isolate name
if (isolateName != null && isolateName.isNotEmpty) {
final threadName = isolateName;
final threadId = isolateName.hashCode.toString();

span.setData(SpanDataConvention.threadId, threadId);
span.setData(SpanDataConvention.threadName, threadName);
}
}

@override
Future<void> onSpanFinished(ISentrySpan span, DateTime endTimestamp) async {
// No-op: we only need to set data when span starts
}

@override
void clear() {
// No-op: thread info doesn't change during execution
}
}
49 changes: 49 additions & 0 deletions flutter/test/sentry_flutter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:sentry_flutter/src/replay/integration.dart';
import 'package:sentry_flutter/src/version.dart';
import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_integration.dart';
import 'package:sentry_flutter/src/web/javascript_transport.dart';
import 'package:sentry_flutter/src/thread_info_collector.dart';

import 'mocks.dart';
import 'mocks.mocks.dart';
Expand Down Expand Up @@ -693,6 +694,54 @@ void main() {
);
SentryFlutter.native = null;
});

test('ThreadInfoCollector is added when tracing is enabled', () async {
final sentryFlutterOptions =
defaultTestOptions(checker: MockRuntimeChecker())
..platform = MockPlatform.android()
..methodChannel = native.channel
..tracesSampleRate = 1.0; // Enable tracing

SentryFlutter.native = mockNativeBinding();
await SentryFlutter.init(
(options) {
expect(
options.performanceCollectors
.any((collector) => collector is ThreadInfoCollector),
true,
reason:
'ThreadInfoCollector should be added when tracing is enabled',
);
},
appRunner: appRunner,
options: sentryFlutterOptions,
);
SentryFlutter.native = null;
});

test('ThreadInfoCollector is not added when tracing is disabled', () async {
final sentryFlutterOptions =
defaultTestOptions(checker: MockRuntimeChecker())
..platform = MockPlatform.android()
..methodChannel = native.channel
..tracesSampleRate = null; // Disable tracing

SentryFlutter.native = mockNativeBinding();
await SentryFlutter.init(
(options) {
expect(
options.performanceCollectors
.any((collector) => collector is ThreadInfoCollector),
false,
reason:
'ThreadInfoCollector should not be added when tracing is disabled',
);
},
appRunner: appRunner,
options: sentryFlutterOptions,
);
SentryFlutter.native = null;
});
});

test('resumeAppHangTracking calls native method when available', () async {
Expand Down
192 changes: 192 additions & 0 deletions flutter/test/thread_info_collector_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
@TestOn('vm')
library;

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:sentry_flutter/src/thread_info_collector.dart';
import 'package:sentry_flutter/src/isolate_helper.dart';
import 'package:sentry/src/span_data_convention.dart';
import 'package:sentry/src/protocol/sentry_span.dart';
import 'package:sentry/src/sentry_span_context.dart';

void main() {
late _Fixture fixture;

setUp(() {
fixture = _Fixture();
});

group('ThreadInfoCollector', () {
test('sets main thread name when in root isolate', () async {
fixture.mockHelper.setIsRootIsolate(true);
fixture.mockHelper.setIsolateName("main(debug)");

final collector = fixture.getSut();
final span = fixture.createMockSpan();

await collector.onSpanStarted(span);

final setDataCalls = span.setDataCalls;
expect(setDataCalls.length, equals(2));

final threadIdCall = setDataCalls
.firstWhere((call) => call.key == SpanDataConvention.threadId);
final threadNameCall = setDataCalls
.firstWhere((call) => call.key == SpanDataConvention.threadName);

expect(threadIdCall.value, equals('main'.hashCode.toString()));
expect(threadNameCall.value, equals('main'));
});

test('adds thread information when isolate has name', () async {
fixture.mockHelper.setIsRootIsolate(false);
fixture.mockHelper.setIsolateName('worker-thread');

final collector = fixture.getSut();
final span = fixture.createMockSpan();

await collector.onSpanStarted(span);

final setDataCalls = span.setDataCalls;
expect(setDataCalls.length, equals(2));

final threadIdCall = setDataCalls
.firstWhere((call) => call.key == SpanDataConvention.threadId);
final threadNameCall = setDataCalls
.firstWhere((call) => call.key == SpanDataConvention.threadName);

expect(threadIdCall.value, equals('worker-thread'.hashCode.toString()));
expect(threadNameCall.value, equals('worker-thread'));
});

test('onSpanFinished is no-op', () async {
fixture.mockHelper.setIsRootIsolate(false);
final collector = fixture.getSut();
final span = fixture.createMockSpan();

await collector.onSpanFinished(span, DateTime.now());
expect(span.setDataCalls, isEmpty);
});

test('gets thread info dynamically for each span', () async {
fixture.mockHelper.setIsRootIsolate(false);
fixture.mockHelper.setIsolateName('dynamic-test');

final collector = fixture.getSut();
final span = fixture.createMockSpan();

await collector.onSpanStarted(span);
final firstCallCount = span.setDataCalls.length;

final span2 = fixture.createMockSpan();
await collector.onSpanStarted(span2);

// Should have same number of calls for both spans (thread info collected fresh each time)
expect(span2.setDataCalls.length, equals(firstCallCount));

// Both spans should have thread info when isolate has a name
expect(firstCallCount, equals(2));
});

test('uses provided isolate name correctly', () async {
fixture.mockHelper.setIsRootIsolate(false);
fixture.mockHelper.setIsolateName('custom-isolate-name');
final collector = fixture.getSut();
final span = fixture.createMockSpan();

await collector.onSpanStarted(span);

// Find thread data calls
String? threadId;
String? threadName;
for (final call in span.setDataCalls) {
if (call.key == SpanDataConvention.threadId) {
threadId = call.value as String?;
}
if (call.key == SpanDataConvention.threadName) {
threadName = call.value as String?;
}
}

expect(threadName, equals('custom-isolate-name'));
expect(threadId, equals('custom-isolate-name'.hashCode.toString()));
});

test('no thread info when isolate name is null', () async {
fixture.mockHelper.setIsRootIsolate(false);
fixture.mockHelper.setIsolateName(null);
final collector = fixture.getSut();
final span = fixture.createMockSpan();

await collector.onSpanStarted(span);

// When isolate name is null, no thread data should be set
expect(span.setDataCalls, isEmpty);
});

test('no thread info when isolate name is empty', () async {
fixture.mockHelper.setIsRootIsolate(false);
fixture.mockHelper.setIsolateName('');
final collector = fixture.getSut();
final span = fixture.createMockSpan();

await collector.onSpanStarted(span);

// When isolate name is empty, no thread data should be set
expect(span.setDataCalls, isEmpty);
});
});
}

class _Fixture {
late _MockIsolateHelper mockHelper;

_Fixture() {
mockHelper = _MockIsolateHelper();
// Set default return values to avoid null errors
mockHelper.setIsRootIsolate(false);
mockHelper.setIsolateName(null);
}

ThreadInfoCollector getSut() {
return ThreadInfoCollector(mockHelper);
}

_MockSpan createMockSpan() {
return _MockSpan();
}
}

class _MockIsolateHelper extends Mock implements IsolateHelper {
bool _isRootIsolate = false;
String? _isolateName;

@override
bool isRootIsolate() => _isRootIsolate;

@override
String? getIsolateName() => _isolateName;

void setIsRootIsolate(bool value) => _isRootIsolate = value;
void setIsolateName(String? value) => _isolateName = value;
}

class _MockSpan extends Mock implements SentrySpan {
final SentrySpanContext _context = SentrySpanContext(operation: 'test');
final List<_SetDataCall> setDataCalls = [];

@override
SentrySpanContext get context => _context;

@override
void setData(String key, dynamic value) {
setDataCalls.add(_SetDataCall(key, value));
}
}

class _SetDataCall {
final String key;
final dynamic value;

_SetDataCall(this.key, this.value);
}
Loading