diff --git a/packages/dart/lib/sentry.dart b/packages/dart/lib/sentry.dart index f14f3aad16..b3db224cf3 100644 --- a/packages/dart/lib/sentry.dart +++ b/packages/dart/lib/sentry.dart @@ -42,6 +42,7 @@ export 'src/sentry_envelope.dart'; export 'src/sentry_envelope_item.dart'; export 'src/sentry_options.dart'; export 'src/telemetry/sentry_trace_lifecycle.dart'; +export 'src/telemetry/span/sentry_span_v2.dart'; // ignore: invalid_export_of_internal_element export 'src/sentry_trace_origins.dart'; export 'src/span_data_convention.dart'; diff --git a/packages/dart/lib/src/constants.dart b/packages/dart/lib/src/constants.dart index 04c86a500f..273c4797db 100644 --- a/packages/dart/lib/src/constants.dart +++ b/packages/dart/lib/src/constants.dart @@ -119,6 +119,19 @@ abstract class SemanticAttributesConstants { /// The device family (e.g., "iOS", "Android"). static const deviceFamily = 'device.family'; + /// The number of total frames rendered during the lifetime of the span. + static const framesTotal = 'frames.total'; + + /// The number of slow frames rendered during the lifetime of the span. + static const framesSlow = 'frames.slow'; + + /// The number of frozen frames rendered during the lifetime of the span. + static const framesFrozen = 'frames.frozen'; + + /// The sum of all delayed frame durations in seconds during the lifetime of the span. + /// For more information see [frames delay](https://develop.sentry.dev/sdk/performance/frames-delay/). + static const framesDelay = 'frames.delay'; + /// The HTTP request method (e.g., "GET", "POST"). static const httpRequestMethod = 'http.request.method'; diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index 750d47ae62..445847ce44 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -713,6 +713,8 @@ class Hub { scope.setActiveSpan(span); } + _options.lifecycleRegistry.dispatchCallback(OnSpanStartV2(span)); + return span; } diff --git a/packages/dart/lib/src/protocol/sentry_span.dart b/packages/dart/lib/src/protocol/sentry_span.dart index d1dca8b97c..13edc46505 100644 --- a/packages/dart/lib/src/protocol/sentry_span.dart +++ b/packages/dart/lib/src/protocol/sentry_span.dart @@ -14,6 +14,7 @@ class SentrySpan extends ISentrySpan { late final DateTime _startTimestamp; final Hub _hub; + bool _isFinished = false; bool _isRootSpan = false; bool get isRootSpan => _isRootSpan; @@ -51,48 +52,53 @@ class SentrySpan extends ISentrySpan { @override Future finish( {SpanStatus? status, DateTime? endTimestamp, Hint? hint}) async { - if (finished) { + // Prevent concurrent or duplicate finish() calls + if (_isFinished || _endTimestamp != null) { return; } - if (status != null) { - _status = status; - } - - if (endTimestamp == null) { - endTimestamp = _hub.options.clock(); - } else if (endTimestamp.isBefore(_startTimestamp)) { - _hub.options.log( - SentryLevel.warning, - 'End timestamp ($endTimestamp) cannot be before start timestamp ($_startTimestamp)', - ); - endTimestamp = _hub.options.clock(); - } else { - endTimestamp = endTimestamp.toUtc(); - } + try { + if (status != null) { + _status = status; + } - for (final collector in _hub.options.performanceCollectors) { - if (collector is PerformanceContinuousCollector) { - await collector.onSpanFinished(this, endTimestamp); + if (endTimestamp == null) { + endTimestamp = _hub.options.clock(); + } else if (endTimestamp.isBefore(_startTimestamp)) { + _hub.options.log( + SentryLevel.warning, + 'End timestamp ($endTimestamp) cannot be before start timestamp ($_startTimestamp)', + ); + endTimestamp = _hub.options.clock(); + } else { + endTimestamp = endTimestamp.toUtc(); } - } - // Dispatch OnSpanFinish lifecycle event - final callback = - _hub.options.lifecycleRegistry.dispatchCallback(OnSpanFinish(this)); - if (callback is Future) { - await callback; - } + _endTimestamp = endTimestamp; + + // ignore: deprecated_member_use_from_same_package + for (final collector in _hub.options.performanceCollectors) { + if (collector is PerformanceContinuousCollector) { + await collector.onSpanFinished(this, endTimestamp); + } + } - // The finished flag depends on the _endTimestamp - // If we set this earlier then finished is true and then we cannot use setData etc... - _endTimestamp = endTimestamp; + // Dispatch OnSpanFinish lifecycle event + final callback = + _hub.options.lifecycleRegistry.dispatchCallback(OnSpanFinish(this)); + if (callback is Future) { + await callback; + } - // associate error - if (_throwable != null) { - _hub.setSpanContext(_throwable, this, _tracer.name); + // associate error + if (_throwable != null) { + _hub.setSpanContext(_throwable, this, _tracer.name); + } + } finally { + _isFinished = true; + await _finishedCallback?.call(endTimestamp: _endTimestamp, hint: hint); } - await _finishedCallback?.call(endTimestamp: _endTimestamp, hint: hint); + return super .finish(status: status, endTimestamp: _endTimestamp, hint: hint); } @@ -207,7 +213,7 @@ class SentrySpan extends ISentrySpan { } @override - bool get finished => _endTimestamp != null; + bool get finished => _isFinished && _endTimestamp != null; @override dynamic get throwable => _throwable; diff --git a/packages/dart/lib/src/sdk_lifecycle_hooks.dart b/packages/dart/lib/src/sdk_lifecycle_hooks.dart index c5601cc5b7..8fb07296f6 100644 --- a/packages/dart/lib/src/sdk_lifecycle_hooks.dart +++ b/packages/dart/lib/src/sdk_lifecycle_hooks.dart @@ -97,7 +97,16 @@ class OnSpanFinish extends SdkLifecycleEvent { final ISentrySpan span; } -/// Dispatched when span is ready for processing (before default enrichment). +/// Dispatched when a sampled span is started. +@internal +class OnSpanStartV2 extends SdkLifecycleEvent { + OnSpanStartV2(this.span); + + final SentrySpanV2 span; +} + +/// Dispatched when a span has been captured and is ready for processing (before default enrichment) +/// and before it's being added to the telemetry processor. /// /// This is useful for integrations to hook into e.g for enriching with attributes. @internal @@ -107,6 +116,8 @@ class OnProcessSpan extends SdkLifecycleEvent { OnProcessSpan(this.span); } +/// Dispatched when a metric has been captured and is ready for processing (before default enrichment) +/// and before it's being added to the telemetry processor. @internal class OnProcessMetric extends SdkLifecycleEvent { final SentryMetric metric; diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 7ec6f36a8c..f1fe3c2c5f 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -657,10 +657,12 @@ class SentryOptions { return tracesSampleRate != null || tracesSampler != null; } + @Deprecated('Will be removed in the next major v10') List get performanceCollectors => _performanceCollectors; final List _performanceCollectors = []; + @Deprecated('Will be removed in the next major v10') void addPerformanceCollector(PerformanceCollector collector) { _performanceCollectors.add(collector); } diff --git a/packages/dart/lib/src/sentry_tracer.dart b/packages/dart/lib/src/sentry_tracer.dart index 0b8e5468db..2ba65c88d1 100644 --- a/packages/dart/lib/src/sentry_tracer.dart +++ b/packages/dart/lib/src/sentry_tracer.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'dart:async'; import 'package:meta/meta.dart'; diff --git a/packages/dart/lib/src/telemetry/span/noop_sentry_span_v2.dart b/packages/dart/lib/src/telemetry/span/noop_sentry_span_v2.dart index 2408278364..ce3be945a5 100644 --- a/packages/dart/lib/src/telemetry/span/noop_sentry_span_v2.dart +++ b/packages/dart/lib/src/telemetry/span/noop_sentry_span_v2.dart @@ -23,6 +23,9 @@ final class NoOpSentrySpanV2 implements SentrySpanV2 { @override SentrySpanV2? get parentSpan => null; + @override + DateTime get startTimestamp => DateTime.fromMillisecondsSinceEpoch(0); + @override DateTime? get endTimestamp => null; diff --git a/packages/dart/lib/src/telemetry/span/recording_sentry_span_v2.dart b/packages/dart/lib/src/telemetry/span/recording_sentry_span_v2.dart index 4d0318271a..41dd439316 100644 --- a/packages/dart/lib/src/telemetry/span/recording_sentry_span_v2.dart +++ b/packages/dart/lib/src/telemetry/span/recording_sentry_span_v2.dart @@ -117,6 +117,9 @@ final class RecordingSentrySpanV2 implements SentrySpanV2 { @override set status(SentrySpanStatusV2 value) => _status = value; + @override + DateTime get startTimestamp => _startTimestamp; + @override DateTime? get endTimestamp => _endTimestamp; diff --git a/packages/dart/lib/src/telemetry/span/sentry_span_v2.dart b/packages/dart/lib/src/telemetry/span/sentry_span_v2.dart index c5b76ac4c1..1311bfe626 100644 --- a/packages/dart/lib/src/telemetry/span/sentry_span_v2.dart +++ b/packages/dart/lib/src/telemetry/span/sentry_span_v2.dart @@ -32,7 +32,12 @@ sealed class SentrySpanV2 { /// Sets the status of this span. set status(SentrySpanStatusV2 status); + /// The start timestamp of this span. + DateTime get startTimestamp; + /// The end timestamp of this span. + /// + /// Returns null if the span has not ended yet. DateTime? get endTimestamp; /// Whether this span has ended. diff --git a/packages/dart/lib/src/telemetry/span/unset_sentry_span_v2.dart b/packages/dart/lib/src/telemetry/span/unset_sentry_span_v2.dart index 722becc804..a734e7089c 100644 --- a/packages/dart/lib/src/telemetry/span/unset_sentry_span_v2.dart +++ b/packages/dart/lib/src/telemetry/span/unset_sentry_span_v2.dart @@ -26,6 +26,9 @@ final class UnsetSentrySpanV2 implements SentrySpanV2 { @override SentrySpanV2? get parentSpan => _throw(); + @override + DateTime get startTimestamp => _throw(); + @override DateTime? get endTimestamp => _throw(); diff --git a/packages/dart/lib/src/tracing/instrumentation/instrumentation_span.dart b/packages/dart/lib/src/tracing/instrumentation/instrumentation_span.dart index 26a285a7a5..dbea8dc0a6 100644 --- a/packages/dart/lib/src/tracing/instrumentation/instrumentation_span.dart +++ b/packages/dart/lib/src/tracing/instrumentation/instrumentation_span.dart @@ -17,6 +17,12 @@ abstract class InstrumentationSpan { Future finish({SpanStatus? status, DateTime? endTimestamp}); SentryTraceHeader toSentryTrace(); SentryBaggageHeader? toBaggageHeader(); + + /// Returns true if this span is a recording span that records data. + bool get isRecording; + + /// The start timestamp of this span. + DateTime get startTimestamp; } /// [InstrumentationSpan] implementation wrapping [ISentrySpan]. @@ -65,6 +71,24 @@ class LegacyInstrumentationSpan implements InstrumentationSpan { @override SentryBaggageHeader? toBaggageHeader() => _span.toBaggageHeader(); + + @override + bool get isRecording => _span is SentrySpan; + + @override + DateTime get startTimestamp => _span.startTimestamp; + + // Needed so List.remove in SpanFrameMetricsCollector can match different + // wrapper instances that wrap the same underlying span. + @override + bool operator ==(Object other) => + identical(this, other) || + other is LegacyInstrumentationSpan && + runtimeType == other.runtimeType && + identical(_span, other._span); + + @override + int get hashCode => _span.hashCode; } @internal @@ -180,4 +204,22 @@ class StreamingInstrumentationSpan implements InstrumentationSpan { } return SentrySpanStatusV2.error; } + + @override + bool get isRecording => _span is RecordingSentrySpanV2; + + @override + DateTime get startTimestamp => _span.startTimestamp; + + // Needed so List.remove in SpanFrameMetricsCollector can match different + // wrapper instances that wrap the same underlying span. + @override + bool operator ==(Object other) => + identical(this, other) || + other is StreamingInstrumentationSpan && + runtimeType == other.runtimeType && + identical(_span, other._span); + + @override + int get hashCode => _span.hashCode; } diff --git a/packages/flutter/lib/src/frames_tracking/span_frame_metrics_collector.dart b/packages/flutter/lib/src/frames_tracking/span_frame_metrics_collector.dart index 3069fbd374..696cee448a 100644 --- a/packages/flutter/lib/src/frames_tracking/span_frame_metrics_collector.dart +++ b/packages/flutter/lib/src/frames_tracking/span_frame_metrics_collector.dart @@ -3,85 +3,115 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; +import '../utils/internal_logger.dart'; import 'sentry_delayed_frames_tracker.dart'; -/// Collects frames from [SentryDelayedFramesTracker], calculates the metrics -/// and attaches them to spans. +/// Collects frame metrics for both legacy and streaming spans via +/// [InstrumentationSpan] wrappers. @internal -class SpanFrameMetricsCollector implements PerformanceContinuousCollector { +class SpanFrameMetricsCollector { SpanFrameMetricsCollector( - this._options, this._frameTracker, { required void Function() resumeFrameTracking, required void Function() pauseFrameTracking, }) : _resumeFrameTracking = resumeFrameTracking, _pauseFrameTracking = pauseFrameTracking; - final SentryFlutterOptions _options; final SentryDelayedFramesTracker _frameTracker; final void Function() _resumeFrameTracking; final void Function() _pauseFrameTracking; - /// Stores the spans that are actively being tracked. - /// After the frames are calculated and stored in the span the span is removed from this list. + /// Frame tracking pauses when empty. + final List _activeSpans = []; + @visibleForTesting - final List activeSpans = []; + List get activeSpans => _activeSpans; - @override - Future onSpanStarted(ISentrySpan span) async { - return _tryCatch('onSpanFinished', () async { - if (span is NoOpSentrySpan) { + Future startTracking(InstrumentationSpan span) async { + return _tryCatch('onSpanStarted', () async { + if (!span.isRecording) { return; } - activeSpans.add(span); + _activeSpans.add(span); _resumeFrameTracking(); }); } - @override - Future onSpanFinished(ISentrySpan span, DateTime endTimestamp) async { + Future finishTracking( + InstrumentationSpan span, + DateTime endTimestamp, + ) async { return _tryCatch('onSpanFinished', () async { - if (span is NoOpSentrySpan) { + if (!span.isRecording) { return; } final startTimestamp = span.startTimestamp; final metrics = _frameTracker.getFrameMetrics( - spanStartTimestamp: startTimestamp, spanEndTimestamp: endTimestamp); - metrics?.applyTo(span); - - activeSpans.remove(span); - if (activeSpans.isEmpty) { - clear(); - } else { - _frameTracker.removeIrrelevantFrames(activeSpans.first.startTimestamp); + spanStartTimestamp: startTimestamp, + spanEndTimestamp: endTimestamp, + ); + + if (metrics != null) { + span.applyFrameMetrics(metrics); } + + removeFromActiveSpans(span); }); } - // TODO: there's already a similar implementation: [SentryNativeSafeInvoker] - // let's try to reuse it at some point + void removeFromActiveSpans(InstrumentationSpan span) { + _activeSpans.remove(span); + if (_activeSpans.isEmpty) { + clear(); + } else { + _frameTracker.removeIrrelevantFrames(_activeSpans.first.startTimestamp); + } + } + Future _tryCatch(String methodName, Future Function() fn) async { try { - return fn(); + return await fn(); } catch (exception, stackTrace) { - _options.log( - SentryLevel.error, + internalLogger.error( 'SpanFrameMetricsCollector $methodName failed', - exception: exception, + error: exception, stackTrace: stackTrace, ); clear(); } } - @override void clear() { _pauseFrameTracking(); _frameTracker.clear(); - activeSpans.clear(); - // we don't need to clear the expected frame duration as that realistically - // won't change throughout the application's lifecycle + _activeSpans.clear(); + } +} + +extension _InstrumentationSpanFrameMetrics on InstrumentationSpan { + void applyFrameMetrics(SpanFrameMetrics metrics) { + if (this is LegacyInstrumentationSpan) { + metrics.applyTo((this as LegacyInstrumentationSpan).spanReference); + } else if (this is StreamingInstrumentationSpan) { + final spanRef = (this as StreamingInstrumentationSpan).spanReference; + if (spanRef is RecordingSentrySpanV2) { + final attributes = {}; + attributes[SemanticAttributesConstants.framesTotal] = + SentryAttribute.int(metrics.totalFrameCount); + attributes[SemanticAttributesConstants.framesSlow] = + SentryAttribute.int(metrics.slowFrameCount); + attributes[SemanticAttributesConstants.framesFrozen] = + SentryAttribute.int(metrics.frozenFrameCount); + attributes[SemanticAttributesConstants.framesDelay] = + SentryAttribute.int(metrics.framesDelay); + spanRef.setAttributesIfAbsent(attributes); + } + } else { + internalLogger.warning( + 'Unknown InstrumentationSpan type: $runtimeType', + ); + } } } diff --git a/packages/flutter/lib/src/integrations/frames_tracking_integration.dart b/packages/flutter/lib/src/integrations/frames_tracking_integration.dart index 103b679e4e..27c8a7755e 100644 --- a/packages/flutter/lib/src/integrations/frames_tracking_integration.dart +++ b/packages/flutter/lib/src/integrations/frames_tracking_integration.dart @@ -1,4 +1,4 @@ -// ignore_for_file: invalid_use_of_internal_member +// ignore_for_file: invalid_use_of_internal_member, deprecated_member_use import 'dart:core'; @@ -14,8 +14,12 @@ class FramesTrackingIntegration implements Integration { static const integrationName = 'FramesTracking'; final SentryNativeBinding _native; SentryFlutterOptions? _options; - PerformanceCollector? _collector; + SpanFrameMetricsCollector? _collector; SentryWidgetsBindingMixin? _widgetsBinding; + SdkLifecycleCallback? _onSpanStartStreamCallback; + SdkLifecycleCallback? _onProcessSpanStreamCallback; + SdkLifecycleCallback? _onSpanStartStaticCallback; + SdkLifecycleCallback? _onSpanFinishStaticCallback; @override Future call(Hub hub, SentryFlutterOptions options) async { @@ -50,12 +54,58 @@ class FramesTrackingIntegration implements Integration { SentryDelayedFramesTracker(options, expectedFrameDuration); widgetsBinding.initializeFramesTracking( framesTracker.addDelayedFrame, options, expectedFrameDuration); - final collector = SpanFrameMetricsCollector(options, framesTracker, - resumeFrameTracking: () => widgetsBinding.resumeTrackingFrames(), - pauseFrameTracking: () => widgetsBinding.pauseTrackingFrames()); - options.addPerformanceCollector(collector); + + final collector = SpanFrameMetricsCollector( + framesTracker, + resumeFrameTracking: () => widgetsBinding.resumeTrackingFrames(), + pauseFrameTracking: () => widgetsBinding.pauseTrackingFrames(), + ); _collector = collector; + switch (options.traceLifecycle) { + case SentryTraceLifecycle.streaming: + _onSpanStartStreamCallback = (event) { + final wrapped = StreamingInstrumentationSpan(event.span); + collector.startTracking(wrapped); + }; + options.lifecycleRegistry + .registerCallback(_onSpanStartStreamCallback!); + + _onProcessSpanStreamCallback = (event) { + final wrapped = StreamingInstrumentationSpan(event.span); + if (event.span.endTimestamp != null) { + collector.finishTracking(wrapped, event.span.endTimestamp!); + } else { + options.log(SentryLevel.warning, + 'OnProcessSpan fired but span has no endTimestamp'); + collector.removeFromActiveSpans(wrapped); + } + }; + options.lifecycleRegistry + .registerCallback(_onProcessSpanStreamCallback!); + + case SentryTraceLifecycle.static: + _onSpanStartStaticCallback = (event) { + final wrapped = LegacyInstrumentationSpan(event.span); + collector.startTracking(wrapped); + }; + options.lifecycleRegistry + .registerCallback(_onSpanStartStaticCallback!); + + _onSpanFinishStaticCallback = (event) { + final wrapped = LegacyInstrumentationSpan(event.span); + if (event.span.endTimestamp != null) { + collector.finishTracking(wrapped, event.span.endTimestamp!); + } else { + options.log(SentryLevel.warning, + 'OnSpanFinish fired but span has no endTimestamp'); + collector.removeFromActiveSpans(wrapped); + } + }; + options.lifecycleRegistry + .registerCallback(_onSpanFinishStaticCallback!); + } + options.sdk.addIntegration(integrationName); options.log(SentryLevel.debug, '$FramesTrackingIntegration successfully initialized with an expected frame duration of ${expectedFrameDuration.inMilliseconds}ms'); @@ -71,7 +121,26 @@ class FramesTrackingIntegration implements Integration { @override void close() { - _options?.performanceCollectors.remove(_collector); + final options = _options; + if (options != null) { + if (_onSpanStartStreamCallback != null) { + options.lifecycleRegistry + .removeCallback(_onSpanStartStreamCallback!); + } + if (_onProcessSpanStreamCallback != null) { + options.lifecycleRegistry + .removeCallback(_onProcessSpanStreamCallback!); + } + if (_onSpanStartStaticCallback != null) { + options.lifecycleRegistry + .removeCallback(_onSpanStartStaticCallback!); + } + if (_onSpanFinishStaticCallback != null) { + options.lifecycleRegistry + .removeCallback(_onSpanFinishStaticCallback!); + } + } + _collector?.clear(); _widgetsBinding?.removeFramesTracking(); } } diff --git a/packages/flutter/test/frame_tracking/frames_tracking_integration_test.dart b/packages/flutter/test/frame_tracking/frames_tracking_integration_test.dart index e5089a761b..1e36fdc317 100644 --- a/packages/flutter/test/frame_tracking/frames_tracking_integration_test.dart +++ b/packages/flutter/test/frame_tracking/frames_tracking_integration_test.dart @@ -8,6 +8,7 @@ import 'package:mockito/mockito.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/binding_wrapper.dart'; +import 'package:sentry_flutter/src/frames_tracking/span_frame_metrics_collector.dart'; import 'package:sentry_flutter/src/integrations/frames_tracking_integration.dart'; import '../binding.dart'; @@ -63,7 +64,8 @@ void main() { if (widgetsBinding != null) { expect(isFramesTrackingInitialized(widgetsBinding!), isFalse); } - expect(options.performanceCollectors, isEmpty); + // Both streaming and static lifecycles use lifecycle callbacks, not performance collectors + expect(options.lifecycleRegistry.lifecycleCallbacks, isEmpty); } setUp(() { @@ -84,95 +86,199 @@ void main() { contains(FramesTrackingIntegration.integrationName)); }); - test('properly cleans up resources on close', () async { + test('properly cleans up resources on close - streaming', () async { + options.traceLifecycle = SentryTraceLifecycle.streaming; await fromWorkingState(options); expect(isFramesTrackingInitialized(widgetsBinding!), isTrue); - expect(options.performanceCollectors, isNotEmpty); + expect( + options.lifecycleRegistry.lifecycleCallbacks.containsKey(OnSpanStartV2), + isTrue, + ); + expect( + options.lifecycleRegistry.lifecycleCallbacks.containsKey(OnProcessSpan), + isTrue, + ); integration.close(); expect(isFramesTrackingInitialized(widgetsBinding!), isFalse); - expect(options.performanceCollectors, isEmpty); + expect( + options.lifecycleRegistry.lifecycleCallbacks[OnSpanStartV2], + isEmpty, + ); + expect( + options.lifecycleRegistry.lifecycleCallbacks[OnProcessSpan], + isEmpty, + ); + }); + + test('properly cleans up resources on close - static', () async { + options.traceLifecycle = SentryTraceLifecycle.static; + await fromWorkingState(options); + + expect(isFramesTrackingInitialized(widgetsBinding!), isTrue); + expect( + options.lifecycleRegistry.lifecycleCallbacks.containsKey(OnSpanStart), + isTrue, + ); + expect( + options.lifecycleRegistry.lifecycleCallbacks.containsKey(OnSpanFinish), + isTrue, + ); + + integration.close(); + + expect(isFramesTrackingInitialized(widgetsBinding!), isFalse); + expect( + options.lifecycleRegistry.lifecycleCallbacks[OnSpanStart], + isEmpty, + ); + expect( + options.lifecycleRegistry.lifecycleCallbacks[OnSpanFinish], + isEmpty, + ); }); group('succeeds to initialize frames tracking', () { late Hub hub; + const slowFrame = Duration(milliseconds: 100); + const frozenFrame = Duration(milliseconds: 800); + setUp(() async { hub = Hub(options); - await fromWorkingState(options); }); - testWidgets('measures frames', (WidgetTester tester) async { - SentryTracer? tracer; - ISentrySpan? child; + Future simulateFrames( + WidgetTester tester, { + required List frameDurations, + }) async { + for (final d in frameDurations) { + tester.binding.handleBeginFrame(Duration.zero); + await Future.delayed(d); + tester.binding.handleDrawFrame(); + } + } - await tester.runAsync(() async { - // Widget to be rendered - Widget testWidget = MaterialApp( - home: Scaffold( - body: Center( - child: ElevatedButton( - child: Text('Start Transaction'), - onPressed: () { - tracer = hub.startTransaction( - 'test_transaction', 'test_operation', - bindToScope: true, - startTimestamp: options.clock()) as SentryTracer; - child = tracer?.startChild('child_operation', - description: 'Child span', - startTimestamp: options.clock()); - }, - ), + Widget buildTestApp({ + required String buttonText, + required VoidCallback onPressed, + }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButton( + onPressed: onPressed, + child: Text(buttonText), ), ), + ), + ); + } + + testWidgets('with stream lifecycle measures frames', + (WidgetTester tester) async { + late SentrySpanV2 parentSpan; + late SentrySpanV2 childSpan; + + await fromWorkingState( + options..traceLifecycle = SentryTraceLifecycle.streaming); + + await tester.runAsync(() async { + final testWidget = buildTestApp( + buttonText: 'Start Span', + onPressed: () { + parentSpan = hub.startSpan('test_parent_span'); + childSpan = hub.startSpan('test_child_span'); + }, ); await tester.pumpWidget(testWidget); - await tester.tap(find.byType(ElevatedButton)); - /// Generates 2 slow and 1 frozen frame for the child span - Future _simulateChildSpanFrames() async { - tester.binding.handleBeginFrame(Duration()); - await Future.delayed(Duration(milliseconds: 100)); - tester.binding.handleDrawFrame(); + // Child span: 2 slow + 1 frozen + await simulateFrames( + tester, + frameDurations: const [slowFrame, slowFrame, frozenFrame], + ); + childSpan.end(); - tester.binding.handleBeginFrame(Duration()); - await Future.delayed(Duration(milliseconds: 100)); - tester.binding.handleDrawFrame(); + // Parent span: 3 slow + 1 frozen (parent should also include child's) + await simulateFrames( + tester, + frameDurations: const [slowFrame, slowFrame, slowFrame, frozenFrame], + ); + parentSpan.end(); - tester.binding.handleBeginFrame(Duration()); - await Future.delayed(Duration(milliseconds: 800)); - tester.binding.handleDrawFrame(); - } + expect( + childSpan.attributes[SemanticAttributesConstants.framesSlow]?.value, + 2, + ); + expect( + childSpan.attributes[SemanticAttributesConstants.framesFrozen]?.value, + 1, + ); - await _simulateChildSpanFrames(); - await child?.finish(endTimestamp: options.clock()); + expect( + parentSpan.attributes[SemanticAttributesConstants.framesSlow]?.value, + 5, + ); + expect( + parentSpan + .attributes[SemanticAttributesConstants.framesFrozen]?.value, + 2, + ); + expect( + parentSpan.attributes[SemanticAttributesConstants.framesTotal]?.value, + greaterThanOrEqualTo(8), + ); + // No delay assertions due to test env timing flakiness. + }); + }); + + testWidgets('with static lifecycle measures frames', + (WidgetTester tester) async { + SentryTracer? tracer; + ISentrySpan? child; - /// Generates 3 slow and 1 frozen frame for the tracer - /// However when asserting later, the tracer will also include the number - /// of slow and frozen frames from the child span. - Future _simulateTracerFrames() async { - tester.binding.handleBeginFrame(Duration()); - await Future.delayed(Duration(milliseconds: 100)); - tester.binding.handleDrawFrame(); + await fromWorkingState( + options..traceLifecycle = SentryTraceLifecycle.static); - tester.binding.handleBeginFrame(Duration()); - await Future.delayed(Duration(milliseconds: 100)); - tester.binding.handleDrawFrame(); + await tester.runAsync(() async { + final testWidget = buildTestApp( + buttonText: 'Start Transaction', + onPressed: () { + tracer = hub.startTransaction( + 'test_transaction', + 'test_operation', + bindToScope: true, + startTimestamp: options.clock(), + ) as SentryTracer; + + child = tracer?.startChild( + 'child_operation', + description: 'Child span', + startTimestamp: options.clock(), + ); + }, + ); - tester.binding.handleBeginFrame(Duration()); - await Future.delayed(Duration(milliseconds: 100)); - tester.binding.handleDrawFrame(); + await tester.pumpWidget(testWidget); + await tester.tap(find.byType(ElevatedButton)); - tester.binding.handleBeginFrame(Duration()); - await Future.delayed(Duration(milliseconds: 800)); - tester.binding.handleDrawFrame(); - } + // Child span: 2 slow + 1 frozen + await simulateFrames( + tester, + frameDurations: const [slowFrame, slowFrame, frozenFrame], + ); + await child?.finish(endTimestamp: options.clock()); - await _simulateTracerFrames(); + // Tracer: 3 slow + 1 frozen (and will include child's) + await simulateFrames( + tester, + frameDurations: const [slowFrame, slowFrame, slowFrame, frozenFrame], + ); await tracer?.finish(endTimestamp: options.clock()); expect(tracer, isNotNull); @@ -185,16 +291,20 @@ void main() { // Verify tracer expect(tracer!.data['frames.slow'] as int, 5); expect(tracer!.data['frames.frozen'] as int, 2); + expect( - (tracer!.measurements['frames_total'] as SentryMeasurement).value, - greaterThanOrEqualTo(8)); - expect((tracer!.measurements['frames_slow'] as SentryMeasurement).value, - 5); + (tracer!.measurements['frames_total'] as SentryMeasurement).value, + greaterThanOrEqualTo(8), + ); expect( - (tracer!.measurements['frames_frozen'] as SentryMeasurement).value, - 2); - // we don't measure the frames delay or total frames because the timings are not - // completely accurate in a test env so it may flake + (tracer!.measurements['frames_slow'] as SentryMeasurement).value, + 5, + ); + expect( + (tracer!.measurements['frames_frozen'] as SentryMeasurement).value, + 2, + ); + // No delay assertions due to test env timing flakiness. }); }); }); @@ -226,4 +336,135 @@ void main() { assertInitFailure(); }); }); + + group('with streaming lifecycle', () { + setUp(() { + options.traceLifecycle = SentryTraceLifecycle.streaming; + }); + + test('registers lifecycle callbacks', () async { + await fromWorkingState(options); + + expect( + options.lifecycleRegistry.lifecycleCallbacks.containsKey(OnSpanStartV2), + isTrue, + ); + expect( + options.lifecycleRegistry.lifecycleCallbacks.containsKey(OnProcessSpan), + isTrue, + ); + }); + }); + + group('with static lifecycle', () { + setUp(() { + options.traceLifecycle = SentryTraceLifecycle.static; + }); + + test('registers lifecycle callbacks', () async { + await fromWorkingState(options); + + expect( + options.lifecycleRegistry.lifecycleCallbacks.containsKey(OnSpanStart), + isTrue, + ); + expect( + options.lifecycleRegistry.lifecycleCallbacks.containsKey(OnSpanFinish), + isTrue, + ); + }); + + test('cleans up when span with null endTimestamp is last active span', + () async { + await fromWorkingState(options); + + final mockFrameTracker = MockSentryDelayedFramesTracker(); + int pauseFrameTrackingCalledCount = 0; + + // Create a new collector with our own counters + final testCollector = SpanFrameMetricsCollector( + mockFrameTracker, + resumeFrameTracking: () => widgetsBinding!.resumeTrackingFrames(), + pauseFrameTracking: () { + pauseFrameTrackingCalledCount++; + widgetsBinding!.pauseTrackingFrames(); + }, + ); + + // Simulate a span starting + final hub = Hub(options); + final tracer = hub.startTransaction( + 'test_transaction', + 'test_operation', + bindToScope: true, + startTimestamp: options.clock(), + ) as SentryTracer; + + final span = tracer.startChild( + 'child_operation', + description: 'Child span', + startTimestamp: options.clock(), + ) as SentrySpan; + + final wrapped = LegacyInstrumentationSpan(span); + await testCollector.startTracking(wrapped); + + expect(testCollector.activeSpans, contains(wrapped)); + + // Simulate what happens with null endTimestamp (integration code path) + testCollector.activeSpans.remove(wrapped); + if (testCollector.activeSpans.isEmpty) { + testCollector.clear(); + } + + // Verify cleanup: pauseFrameTracking should be called when activeSpans becomes empty + expect(testCollector.activeSpans, isEmpty); + expect(pauseFrameTrackingCalledCount, 1); + verify(mockFrameTracker.clear()).called(1); + }); + }); + + group('with streaming lifecycle', () { + setUp(() { + options.traceLifecycle = SentryTraceLifecycle.streaming; + }); + + test('cleans up when span with null endTimestamp is last active span', + () async { + await fromWorkingState(options); + + final mockFrameTracker = MockSentryDelayedFramesTracker(); + int pauseFrameTrackingCalledCount = 0; + + // Create a new collector with our own counters + final testCollector = SpanFrameMetricsCollector( + mockFrameTracker, + resumeFrameTracking: () => widgetsBinding!.resumeTrackingFrames(), + pauseFrameTracking: () { + pauseFrameTrackingCalledCount++; + widgetsBinding!.pauseTrackingFrames(); + }, + ); + + // Simulate a span starting + final hub = Hub(options); + final span = hub.startSpan('test_span') as RecordingSentrySpanV2; + + final wrapped = StreamingInstrumentationSpan(span); + await testCollector.startTracking(wrapped); + + expect(testCollector.activeSpans, contains(wrapped)); + + // Simulate what happens with null endTimestamp (integration code path) + testCollector.activeSpans.remove(wrapped); + if (testCollector.activeSpans.isEmpty) { + testCollector.clear(); + } + + // Verify cleanup: pauseFrameTracking should be called when activeSpans becomes empty + expect(testCollector.activeSpans, isEmpty); + expect(pauseFrameTrackingCalledCount, 1); + verify(mockFrameTracker.clear()).called(1); + }); + }); } diff --git a/packages/flutter/test/frame_tracking/span_frame_metrics_collector_test.dart b/packages/flutter/test/frame_tracking/span_frame_metrics_collector_test.dart index f2dd88d81c..bb57cb4dd1 100644 --- a/packages/flutter/test/frame_tracking/span_frame_metrics_collector_test.dart +++ b/packages/flutter/test/frame_tracking/span_frame_metrics_collector_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: invalid_use_of_internal_member + import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; @@ -15,42 +17,20 @@ void main() { fixture = Fixture(); }); - test('clear() clears activeSpans and frame tracker', () async { - final sut = fixture.getSut(); - final span = MockSentrySpan(); - await sut.onSpanStarted(span); - - sut.clear(); - - expect(sut.activeSpans, isEmpty); - verify(fixture.mockFrameTracker.clear()).called(1); - }); - - group('onSpanStarted', () { - test('adds span to activeSpans and resumes tracker', () async { - final sut = fixture.getSut(); - final span = MockSentrySpan(); - await sut.onSpanStarted(span); - - expect(sut.activeSpans, contains(span)); - expect(fixture.resumeFrameTrackingCalledCount, 1); - }); - - test('ignores NoOpSentrySpan', () async { + group('SpanFrameMetricsCollector with LegacyInstrumentationSpan', () { + test('filters out NoOpSentrySpan', () async { final sut = fixture.getSut(); - final span = NoOpSentrySpan(); + final noopSpan = NoOpSentrySpan(); + final wrapper = LegacyInstrumentationSpan(noopSpan); - await sut.onSpanStarted(span); + await sut.startTracking(wrapper); expect(sut.activeSpans, isEmpty); expect(fixture.resumeFrameTrackingCalledCount, 0); }); - }); - group('onSpanFinished', () { - test('applies metrics and removes span from activeSpans', () async { + test('tracks legacy spans and applies metrics', () async { final sut = fixture.getSut(); - // ignore: invalid_use_of_internal_member final startTime = fixture.options.clock(); final endTime = startTime.add(Duration(seconds: 1)); fixture.options.tracesSampleRate = 1.0; @@ -59,6 +39,7 @@ void main() { startTimestamp: startTime); final span = SentrySpan(tracer, SentrySpanContext(operation: 'op'), hub, startTimestamp: startTime, isRootSpan: true); + final wrapper = LegacyInstrumentationSpan(span); final metrics = SpanFrameMetrics( totalFrameCount: 10, @@ -71,14 +52,12 @@ void main() { spanEndTimestamp: endTime, )).thenReturn(metrics); - // First add the span - await sut.onSpanStarted(span); - expect(sut.activeSpans, contains(span)); + await sut.startTracking(wrapper); + expect(sut.activeSpans, contains(wrapper)); + expect(fixture.resumeFrameTrackingCalledCount, 1); - // Then finish it - await sut.onSpanFinished(span, endTime); + await sut.finishTracking(wrapper, endTime); - // Verify actual data on the span expect(tracer.data[SpanDataConvention.totalFrames], 10); expect(tracer.data[SpanDataConvention.slowFrames], 2); expect(tracer.data[SpanDataConvention.frozenFrames], 1); @@ -87,33 +66,29 @@ void main() { expect(tracer.measurements[SentryMeasurement.slowFramesName]?.value, 2); expect(tracer.measurements[SentryMeasurement.frozenFramesName]?.value, 1); expect( - // This code verifies that the delay measurements added to the SentryTracer - // are correctly capturing the framesDelay value. The test checks if the - // framesDelayName measurement in the tracer accurately reflects the expected - tracer.measurements[SentryMeasurement.framesDelayName]?.value, - 500); + tracer.measurements[SentryMeasurement.framesDelayName]?.value, 500); expect(sut.activeSpans, isEmpty); }); - test('applies metrics to multiple spans and removes spans from activeSpans', - () async { + test('handles multiple concurrent legacy spans', () async { final sut = fixture.getSut(); fixture.options.tracesSampleRate = 1.0; final hub = Hub(fixture.options); - // ignore: invalid_use_of_internal_member final startTimeForSpan1 = fixture.options.clock(); final endTimeForSpan1 = startTimeForSpan1.add(Duration(seconds: 1)); final tracer = SentryTracer(SentryTransactionContext('name', 'op'), hub, startTimestamp: startTimeForSpan1); final span1 = SentrySpan(tracer, SentrySpanContext(operation: 'op'), hub, startTimestamp: startTimeForSpan1, isRootSpan: true); + final wrapper1 = LegacyInstrumentationSpan(span1); final startTimeForSpan2 = startTimeForSpan1.add(Duration(milliseconds: 100)); final endTimeForSpan2 = startTimeForSpan2.add(Duration(seconds: 100)); final span2 = span1.startChild('child op', startTimestamp: startTimeForSpan2) as SentrySpan; + final wrapper2 = LegacyInstrumentationSpan(span2); final metricsForSpan1 = SpanFrameMetrics( totalFrameCount: 10, @@ -137,16 +112,13 @@ void main() { spanEndTimestamp: endTimeForSpan2, )).thenReturn(metricsForSpan2); - // First add the spans - await sut.onSpanStarted(span1); - await sut.onSpanStarted(span2); - expect(sut.activeSpans, containsAll([span1, span2])); + await sut.startTracking(wrapper1); + await sut.startTracking(wrapper2); + expect(sut.activeSpans, containsAll([wrapper1, wrapper2])); - // Then finish them - await sut.onSpanFinished(span2, endTimeForSpan2); - await sut.onSpanFinished(span1, endTimeForSpan1); + await sut.finishTracking(wrapper2, endTimeForSpan2); + await sut.finishTracking(wrapper1, endTimeForSpan1); - // Verify root data expect(tracer.data[SpanDataConvention.totalFrames], 10); expect(tracer.data[SpanDataConvention.slowFrames], 2); expect(tracer.data[SpanDataConvention.frozenFrames], 1); @@ -157,7 +129,6 @@ void main() { expect( tracer.measurements[SentryMeasurement.framesDelayName]?.value, 500); - // Verify child span data expect(span2.data[SpanDataConvention.totalFrames], 5); expect(span2.data[SpanDataConvention.slowFrames], 1); expect(span2.data[SpanDataConvention.frozenFrames], 3); @@ -165,11 +136,157 @@ void main() { expect(sut.activeSpans, isEmpty); }); + }); - test('clears tracker when no active span', () async { + group('SpanFrameMetricsCollector with StreamingInstrumentationSpan', () { + test('filters out non-recording SentrySpanV2', () async { + final sut = fixture.getSut(); + final noopSpan = NoOpSentrySpanV2(); + final wrapper = StreamingInstrumentationSpan(noopSpan); + + await sut.startTracking(wrapper); + + expect(sut.activeSpans, isEmpty); + expect(fixture.resumeFrameTrackingCalledCount, 0); + }); + + test('tracks streaming spans and applies attributes', () async { + final sut = fixture.getSut(); + final span = RecordingSentrySpanV2.root( + name: 'test-op', + traceId: SentryId.newId(), + onSpanEnd: (_) async {}, + clock: fixture.options.clock, + dscCreator: (span) => SentryTraceContextHeader(span.traceId, 'key'), + samplingDecision: SentryTracesSamplingDecision(true), + ); + final wrapper = StreamingInstrumentationSpan(span); + final startTime = span.startTimestamp; + final endTime = startTime.add(Duration(seconds: 1)); + + final metrics = SpanFrameMetrics( + totalFrameCount: 15, + slowFrameCount: 3, + frozenFrameCount: 2, + framesDelay: 600, + ); + when(fixture.mockFrameTracker.getFrameMetrics( + spanStartTimestamp: startTime, + spanEndTimestamp: endTime, + )).thenReturn(metrics); + + await sut.startTracking(wrapper); + expect(sut.activeSpans, contains(wrapper)); + expect(fixture.resumeFrameTrackingCalledCount, 1); + + await sut.finishTracking(wrapper, endTime); + + expect( + span.attributes[SemanticAttributesConstants.framesTotal]?.value, 15); + expect(span.attributes[SemanticAttributesConstants.framesSlow]?.value, 3); + expect( + span.attributes[SemanticAttributesConstants.framesFrozen]?.value, 2); + expect( + span.attributes[SemanticAttributesConstants.framesDelay]?.value, 600); + expect(sut.activeSpans, isEmpty); + }); + + test('handles multiple concurrent streaming spans', () async { + final sut = fixture.getSut(); + + final span1 = RecordingSentrySpanV2.root( + name: 'test-op-1', + traceId: SentryId.newId(), + onSpanEnd: (_) async {}, + clock: fixture.options.clock, + dscCreator: (span) => SentryTraceContextHeader(span.traceId, 'key'), + samplingDecision: SentryTracesSamplingDecision(true), + ); + final wrapper1 = StreamingInstrumentationSpan(span1); + final startTime1 = span1.startTimestamp; + final endTime1 = startTime1.add(Duration(seconds: 1)); + + final span2 = RecordingSentrySpanV2.root( + name: 'test-op-2', + traceId: SentryId.newId(), + onSpanEnd: (_) async {}, + clock: fixture.options.clock, + dscCreator: (span) => SentryTraceContextHeader(span.traceId, 'key'), + samplingDecision: SentryTracesSamplingDecision(true), + ); + final wrapper2 = StreamingInstrumentationSpan(span2); + final startTime2 = span2.startTimestamp; + final endTime2 = startTime2.add(Duration(seconds: 2)); + + final metrics1 = SpanFrameMetrics( + totalFrameCount: 10, + slowFrameCount: 1, + frozenFrameCount: 0, + framesDelay: 100, + ); + when(fixture.mockFrameTracker.getFrameMetrics( + spanStartTimestamp: startTime1, + spanEndTimestamp: endTime1, + )).thenReturn(metrics1); + + final metrics2 = SpanFrameMetrics( + totalFrameCount: 20, + slowFrameCount: 4, + frozenFrameCount: 2, + framesDelay: 800, + ); + when(fixture.mockFrameTracker.getFrameMetrics( + spanStartTimestamp: startTime2, + spanEndTimestamp: endTime2, + )).thenReturn(metrics2); + + await sut.startTracking(wrapper1); + await sut.startTracking(wrapper2); + expect(sut.activeSpans, containsAll([wrapper1, wrapper2])); + + await sut.finishTracking(wrapper1, endTime1); + await sut.finishTracking(wrapper2, endTime2); + + expect( + span1.attributes[SemanticAttributesConstants.framesTotal]?.value, 10); + expect( + span1.attributes[SemanticAttributesConstants.framesSlow]?.value, 1); + expect( + span1.attributes[SemanticAttributesConstants.framesFrozen]?.value, 0); + expect(span1.attributes[SemanticAttributesConstants.framesDelay]?.value, + 100); + + expect( + span2.attributes[SemanticAttributesConstants.framesTotal]?.value, 20); + expect( + span2.attributes[SemanticAttributesConstants.framesSlow]?.value, 4); + expect( + span2.attributes[SemanticAttributesConstants.framesFrozen]?.value, 2); + expect(span2.attributes[SemanticAttributesConstants.framesDelay]?.value, + 800); + + expect(sut.activeSpans, isEmpty); + }); + }); + + group('SpanFrameMetricsCollector common behavior', () { + test('clear() clears activeSpans and frame tracker', () async { final sut = fixture.getSut(); final span = MockSentrySpan(); - // ignore: invalid_use_of_internal_member + final wrapper = LegacyInstrumentationSpan(span); + await sut.startTracking(wrapper); + + sut.clear(); + + expect(sut.activeSpans, isEmpty); + verify(fixture.mockFrameTracker.clear()).called(1); + expect(fixture.pauseFrameTrackingCalledCount, 1); + }); + + test('clears tracker when activeSpans becomes empty', () async { + final sut = fixture.getSut(); + final span = MockSentrySpan(); + final wrapper = LegacyInstrumentationSpan(span); final startTime = fixture.options.clock(); final endTime = startTime.add(Duration(seconds: 1)); when(span.startTimestamp).thenReturn(startTime); @@ -177,39 +294,152 @@ void main() { spanStartTimestamp: startTime, spanEndTimestamp: endTime)) .thenReturn(null); - await sut.onSpanFinished(span, endTime); + await sut.startTracking(wrapper); + await sut.finishTracking(wrapper, endTime); verify(fixture.mockFrameTracker.clear()).called(1); expect(fixture.pauseFrameTrackingCalledCount, 1); }); - test('does not clear tracker when active spans exist', () async { + test('removes irrelevant frames when spans remain', () async { final sut = fixture.getSut(); - final span = MockSentrySpan(); + final span1 = MockSentrySpan(); final span2 = MockSentrySpan(); + final wrapper1 = LegacyInstrumentationSpan(span1); + final wrapper2 = LegacyInstrumentationSpan(span2); - // ignore: invalid_use_of_internal_member final startTime = fixture.options.clock(); final endTime = startTime.add(Duration(seconds: 1)); final startTime2 = startTime.add(Duration(seconds: 2)); - when(span.startTimestamp).thenReturn(startTime); + when(span1.startTimestamp).thenReturn(startTime); when(span2.startTimestamp).thenReturn(startTime2); when(fixture.mockFrameTracker.getFrameMetrics( spanStartTimestamp: startTime, spanEndTimestamp: endTime)) .thenReturn(null); - when(fixture.mockFrameTracker.getFrameMetrics( - spanStartTimestamp: startTime2, - spanEndTimestamp: anyNamed('spanEndTimestamp'))) - .thenReturn(null); - await sut.onSpanStarted(span); - await sut.onSpanStarted(span2); + await sut.startTracking(wrapper1); + await sut.startTracking(wrapper2); - await sut.onSpanFinished(span, endTime); + await sut.finishTracking(wrapper1, endTime); verifyNever(fixture.mockFrameTracker.clear()); expect(fixture.pauseFrameTrackingCalledCount, 0); - verify(fixture.mockFrameTracker.removeIrrelevantFrames(any)).called(1); + verify(fixture.mockFrameTracker.removeIrrelevantFrames(startTime2)) + .called(1); + }); + + test('correctly removes span using wrapper equality', () async { + final sut = fixture.getSut(); + fixture.options.tracesSampleRate = 1.0; + final hub = Hub(fixture.options); + final startTime = fixture.options.clock(); + final endTime = startTime.add(Duration(seconds: 1)); + + final tracer = SentryTracer(SentryTransactionContext('name', 'op'), hub, + startTimestamp: startTime); + final span = SentrySpan(tracer, SentrySpanContext(operation: 'op'), hub, + startTimestamp: startTime, isRootSpan: true); + + final wrapper1 = LegacyInstrumentationSpan(span); + await sut.startTracking(wrapper1); + expect(sut.activeSpans, contains(wrapper1)); + + final metrics = SpanFrameMetrics( + totalFrameCount: 10, + slowFrameCount: 2, + frozenFrameCount: 1, + framesDelay: 500, + ); + when(fixture.mockFrameTracker.getFrameMetrics( + spanStartTimestamp: startTime, + spanEndTimestamp: endTime, + )).thenReturn(metrics); + + final wrapper2 = LegacyInstrumentationSpan(span); + await sut.finishTracking(wrapper2, endTime); + + expect(sut.activeSpans, isEmpty); + expect(tracer.data[SpanDataConvention.totalFrames], 10); + }); + + test('correctly removes streaming span using wrapper equality', () async { + final sut = fixture.getSut(); + + final span = RecordingSentrySpanV2.root( + name: 'test-op', + traceId: SentryId.newId(), + onSpanEnd: (_) async {}, + clock: fixture.options.clock, + dscCreator: (span) => SentryTraceContextHeader(span.traceId, 'key'), + samplingDecision: SentryTracesSamplingDecision(true), + ); + final startTime = span.startTimestamp; + final endTime = startTime.add(Duration(seconds: 1)); + + final wrapper1 = StreamingInstrumentationSpan(span); + await sut.startTracking(wrapper1); + expect(sut.activeSpans, contains(wrapper1)); + + final metrics = SpanFrameMetrics( + totalFrameCount: 15, + slowFrameCount: 3, + frozenFrameCount: 2, + framesDelay: 600, + ); + when(fixture.mockFrameTracker.getFrameMetrics( + spanStartTimestamp: startTime, + spanEndTimestamp: endTime, + )).thenReturn(metrics); + + final wrapper2 = StreamingInstrumentationSpan(span); + await sut.finishTracking(wrapper2, endTime); + + expect(sut.activeSpans, isEmpty); + expect( + span.attributes[SemanticAttributesConstants.framesTotal]?.value, 15); + }); + + test('handles unknown InstrumentationSpan types gracefully', () async { + final sut = fixture.getSut(); + final unknownSpan = UnknownInstrumentationSpan(); + + await sut.startTracking(unknownSpan); + expect(sut.activeSpans, contains(unknownSpan)); + + final startTime = unknownSpan.startTimestamp; + final endTime = startTime.add(Duration(seconds: 1)); + final metrics = SpanFrameMetrics( + totalFrameCount: 10, + slowFrameCount: 2, + frozenFrameCount: 1, + framesDelay: 500, + ); + when(fixture.mockFrameTracker.getFrameMetrics( + spanStartTimestamp: startTime, + spanEndTimestamp: endTime, + )).thenReturn(metrics); + + await sut.finishTracking(unknownSpan, endTime); + + expect(sut.activeSpans, isEmpty); + }); + + test('handles error in frame tracker gracefully', () async { + final sut = fixture.getSut(); + final span = MockSentrySpan(); + final wrapper = LegacyInstrumentationSpan(span); + final startTime = fixture.options.clock(); + final endTime = startTime.add(Duration(seconds: 1)); + when(span.startTimestamp).thenReturn(startTime); + when(fixture.mockFrameTracker.getFrameMetrics( + spanStartTimestamp: startTime, spanEndTimestamp: endTime)) + .thenThrow(Exception('Frame tracker error')); + + await sut.startTracking(wrapper); + await sut.finishTracking(wrapper, endTime); + + verify(fixture.mockFrameTracker.clear()).called(1); + expect(sut.activeSpans, isEmpty); }); }); } @@ -222,10 +452,65 @@ class Fixture { SpanFrameMetricsCollector getSut() { return SpanFrameMetricsCollector( - options, mockFrameTracker, resumeFrameTracking: () => resumeFrameTrackingCalledCount += 1, pauseFrameTracking: () => pauseFrameTrackingCalledCount += 1, ); } } + +// Mock unknown span type for testing +class UnknownInstrumentationSpan implements InstrumentationSpan { + final DateTime _startTimestamp = DateTime.now(); + + @override + bool get isRecording => true; + + @override + DateTime get startTimestamp => _startTimestamp; + + @override + Future finish({SpanStatus? status, DateTime? endTimestamp}) async {} + + @override + void setData(String key, value) {} + + @override + void setTag(String key, String value) {} + + @override + SpanStatus? get status => null; + + @override + set status(SpanStatus? status) {} + + @override + dynamic get throwable => null; + + @override + set throwable(throwable) {} + + @override + String? get origin => null; + + @override + set origin(String? origin) {} + + @override + SentryTraceHeader toSentryTrace() => generateSentryTraceHeader( + traceId: SentryId.newId(), + spanId: SpanId.newId(), + sampled: false, + ); + + @override + SentryBaggageHeader? toBaggageHeader() => null; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UnknownInstrumentationSpan && runtimeType == other.runtimeType; + + @override + int get hashCode => runtimeType.hashCode; +}