From b70c397ba6a63afd807fdba990344d145d0533ec Mon Sep 17 00:00:00 2001 From: Hameed Habeeblahi Gbolahan Date: Tue, 22 Sep 2026 01:52:18 +0100 Subject: [PATCH 1/6] feat(tracing): Add Sentry.startNewTrace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public API for manually starting a new trace, scoped to a callback. Forks the current scope with a fresh PropagationContext and runs the callback in a Zone using the same _scopeKey mechanism startSpan uses, so transactions, events, and outgoing trace headers inside the callback pick up the new trace while the global scope is untouched. Async callbacks are covered — the zone survives awaits. Closes #3930 --- .../_sentry_testing/lib/src/mock_hub.dart | 3 + packages/dart/lib/src/hub.dart | 53 +++++- packages/dart/lib/src/hub_adapter.dart | 7 + packages/dart/lib/src/noop_hub.dart | 6 + packages/dart/lib/src/sentry.dart | 26 +++ .../span/propagation/tracing_utils.dart | 7 +- packages/dart/test/hub_test.dart | 166 ++++++++++++++++++ packages/dart/test/sentry_test.dart | 16 ++ .../span/propagation/tracing_utils_test.dart | 15 ++ packages/dio/test/mocks/mock_hub.dart | 3 + 10 files changed, 293 insertions(+), 9 deletions(-) diff --git a/packages/_sentry_testing/lib/src/mock_hub.dart b/packages/_sentry_testing/lib/src/mock_hub.dart index c148b4ab02..7245f2b996 100644 --- a/packages/_sentry_testing/lib/src/mock_hub.dart +++ b/packages/_sentry_testing/lib/src/mock_hub.dart @@ -166,6 +166,9 @@ class MockHub with NoSuchMethodProvider implements Hub { @override Scope get scope => _scope; + + @override + Scope get traceScope => scope; } class CaptureEventCall { diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index cf258e7259..bf8ecd9011 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -6,9 +6,10 @@ import 'package:meta/meta.dart'; import '../sentry.dart'; import 'client_reports/discard_reason.dart'; -import 'telemetry/span/transaction/sentry_tracer.dart'; -import 'telemetry/span/sampling/sentry_traces_sampler.dart'; +import 'telemetry/span/propagation/propagation_context.dart'; import 'telemetry/span/sampling/sentry_span_sampling_context.dart'; +import 'telemetry/span/sampling/sentry_traces_sampler.dart'; +import 'telemetry/span/transaction/sentry_tracer.dart'; import 'transport/data_category.dart'; import 'utils/internal_logger.dart'; @@ -341,6 +342,15 @@ class Hub { Scope scope, ScopeCallback? withScope, ) async { + // Inside a `startNewTrace` callback the zone-forked scope carries a + // different propagation context than the hub's scope. Use it so that + // events captured within the callback belong to the new trace, without + // mutating the hub's scope. + final zoneScope = _zoneScope; + if (zoneScope != null && + !identical(zoneScope.propagationContext, scope.propagationContext)) { + scope = scope.clone()..propagationContext = zoneScope.propagationContext; + } if (withScope != null) { try { scope = scope.clone(); @@ -642,7 +652,7 @@ class Hub { // if transactionContext has no sampling decision yet, run the traces sampler var samplingDecision = transactionContext.samplingDecision; - final propagationContext = scope.propagationContext; + final propagationContext = traceScope.propagationContext; // Store the generated/used sampleRand on the propagation context so // that subsequent transactions in the same trace reuse it. propagationContext.sampleRand ??= Random().nextDouble(); @@ -701,6 +711,37 @@ class Hub { /// `Zone.current[_scopeKey]` therefore gives the most recently pushed scope. Scope? get _zoneScope => Zone.current[_scopeKey] as Scope?; + /// The [Scope] whose [PropagationContext] governs the current trace. + /// + /// Inside a [startNewTrace] callback this is the zone-forked scope carrying + /// the new trace; otherwise it is the hub's current [scope]. + @internal + Scope get traceScope => _zoneScope ?? scope; + + /// Starts a new trace and runs [callback] inside it. + /// + /// Everything started within [callback] — transactions, spans and captured + /// events — belongs to a brand-new trace with a fresh trace id, independent + /// of the trace currently held by the hub's scope. Unlike + /// [generateNewTrace], the hub's scope is left untouched, so code running + /// outside the callback keeps its existing trace. + /// + /// The new trace is bound to the callback via a [Zone], so it survives + /// `await`s for asynchronous callbacks. + T startNewTrace(T Function() callback) { + if (!_isEnabled) { + _options.log( + SentryLevel.warning, + "Instance is disabled and this 'startNewTrace' call is a no-op.", + ); + return callback(); + } + final forkedScope = (_zoneScope ?? scope).clone() + ..propagationContext = PropagationContext() + ..span = null; + return runZoned(callback, zoneValues: {_scopeKey: forkedScope}); + } + /// Returns the currently active span, or `null` if no span is in progress. /// /// Resolution order: @@ -858,7 +899,7 @@ class Hub { String name, Map? attributes, ) { - final propagationContext = scope.propagationContext; + final propagationContext = traceScope.propagationContext; final sampleRand = propagationContext.sampleRand ??= Random().nextDouble(); final samplingContext = SentrySamplingContext.forSpanV2( @@ -951,7 +992,7 @@ class Hub { if (samplingDecision == null) return NoOpSentrySpanV2.instance; span = RecordingSentrySpanV2.root( - traceId: scope.propagationContext.traceId, + traceId: traceScope.propagationContext.traceId, name: name, onSpanEnd: captureSpan, clock: options.clock, @@ -1013,7 +1054,7 @@ class Hub { if (samplingDecision == null) return NoOpSentrySpanV2.instance; final span = IdleRecordingSentrySpanV2( - traceId: scope.propagationContext.traceId, + traceId: traceScope.propagationContext.traceId, name: name, onSpanEnd: captureSpan, clock: options.clock, diff --git a/packages/dart/lib/src/hub_adapter.dart b/packages/dart/lib/src/hub_adapter.dart index 93bfd86c65..acbad9b6fd 100644 --- a/packages/dart/lib/src/hub_adapter.dart +++ b/packages/dart/lib/src/hub_adapter.dart @@ -177,6 +177,13 @@ class HubAdapter implements Hub { @override void generateNewTrace() => Sentry.currentHub.generateNewTrace(); + @override + T startNewTrace(T Function() callback) => + Sentry.currentHub.startNewTrace(callback); + + @override + Scope get traceScope => Sentry.currentHub.traceScope; + @override void setSpanContext( dynamic throwable, diff --git a/packages/dart/lib/src/noop_hub.dart b/packages/dart/lib/src/noop_hub.dart index 17bb66813a..d54487c382 100644 --- a/packages/dart/lib/src/noop_hub.dart +++ b/packages/dart/lib/src/noop_hub.dart @@ -129,6 +129,12 @@ class NoOpHub implements Hub { @override void generateNewTrace() {} + @override + T startNewTrace(T Function() callback) => callback(); + + @override + Scope get traceScope => scope; + @override ISentrySpan? getSpan() => null; diff --git a/packages/dart/lib/src/sentry.dart b/packages/dart/lib/src/sentry.dart index 31a226abd9..3b178b789d 100644 --- a/packages/dart/lib/src/sentry.dart +++ b/packages/dart/lib/src/sentry.dart @@ -441,6 +441,32 @@ class Sentry { startTimestamp: startTimestamp, ); + /// Starts a new trace and runs [callback] inside it. + /// + /// Use this when work is not tied to navigation — polling loops, timers, + /// retries — and should not be glued onto whatever trace the scope + /// currently holds. Transactions, spans and events created within + /// [callback] get a fresh trace id; the scope outside the callback keeps + /// its existing trace. + /// + /// The new trace is bound via a [Zone], so asynchronous callbacks are fully + /// covered: + /// + /// ```dart + /// Timer.periodic(const Duration(seconds: 30), (_) { + /// unawaited(Sentry.startNewTrace(() async { + /// final transaction = Sentry.startTransaction('background-poll', 'task'); + /// try { + /// await fetch(); + /// } finally { + /// await transaction.finish(); + /// } + /// })); + /// }); + /// ``` + static T startNewTrace(T Function() callback) => + _hub.startNewTrace(callback); + /// Starts a new span, executes a synchronous [callback], and ends the span /// before returning the callback result. /// diff --git a/packages/dart/lib/src/telemetry/span/propagation/tracing_utils.dart b/packages/dart/lib/src/telemetry/span/propagation/tracing_utils.dart index a844d38e54..9c621df58d 100644 --- a/packages/dart/lib/src/telemetry/span/propagation/tracing_utils.dart +++ b/packages/dart/lib/src/telemetry/span/propagation/tracing_utils.dart @@ -25,11 +25,12 @@ void addTracingHeadersToHttpHeader( addSentryTraceHeaderFromSpan(span, headers); addBaggageHeaderFromSpan(span, headers); } else { + final scope = hub.traceScope; if (hub.options.propagateTraceparent) { - addW3CHeaderFromScope(hub.scope, headers); + addW3CHeaderFromScope(scope, headers); } - addSentryTraceHeaderFromScope(hub.scope, headers); - addBaggageHeaderFromScope(hub.scope, headers); + addSentryTraceHeaderFromScope(scope, headers); + addBaggageHeaderFromScope(scope, headers); } } diff --git a/packages/dart/test/hub_test.dart b/packages/dart/test/hub_test.dart index 9f60c92043..734442a689 100644 --- a/packages/dart/test/hub_test.dart +++ b/packages/dart/test/hub_test.dart @@ -1038,6 +1038,172 @@ void main() { }); }); + group('Hub startNewTrace', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('runs callback and returns its result', () { + final hub = fixture.getSut(); + + final result = hub.startNewTrace(() => 42); + + expect(result, 42); + }); + + test('returns the future of an async callback', () async { + final hub = fixture.getSut(); + + final result = await hub.startNewTrace(() async { + await Future.delayed(Duration.zero); + return 'done'; + }); + + expect(result, 'done'); + }); + + test('runs callback directly when hub is disabled', () async { + final hub = fixture.getSut(); + await hub.close(); + + final result = hub.startNewTrace(() => 'ok'); + + expect(result, 'ok'); + }); + + test('exposes a fresh trace id inside the callback', () { + final hub = fixture.getSut(); + final outerTraceId = hub.scope.propagationContext.traceId; + + final innerTraceId = hub.startNewTrace( + () => hub.traceScope.propagationContext.traceId, + ); + + expect(innerTraceId, isNot(outerTraceId)); + }); + + test('does not modify the hub scope propagation context', () { + final hub = fixture.getSut(); + final outerContext = hub.scope.propagationContext; + final outerTraceId = outerContext.traceId; + outerContext.sampleRand = 0.5; + + hub.startNewTrace(() {}); + + expect(identical(hub.scope.propagationContext, outerContext), isTrue); + expect(hub.scope.propagationContext.traceId, outerTraceId); + expect(hub.scope.propagationContext.sampleRand, 0.5); + expect(hub.traceScope, same(hub.scope)); + }); + + test('startTransaction inside callback uses the new trace id', () { + final hub = fixture.getSut(); + final outerTraceId = hub.scope.propagationContext.traceId; + + final tr = hub.startNewTrace(() => hub.startTransaction('name', 'op')); + + expect(tr.context.traceId, isNot(outerTraceId)); + expect(hub.scope.propagationContext.traceId, outerTraceId); + }); + + test('transactions inside the same callback share one trace id', () { + final hub = fixture.getSut(); + + final (first, second) = hub.startNewTrace(() => ( + hub.startTransaction('first', 'op'), + hub.startTransaction('second', 'op'), + )); + + expect(first.context.traceId, second.context.traceId); + }); + + test('the new trace survives awaits in an async callback', () async { + final hub = fixture.getSut(); + + final (first, second) = await hub.startNewTrace(() async { + final first = hub.startTransaction('first', 'op'); + await Future.delayed(Duration.zero); + final second = hub.startTransaction('second', 'op'); + return (first, second); + }); + + expect(first.context.traceId, second.context.traceId); + expect( + first.context.traceId, + isNot(hub.scope.propagationContext.traceId), + ); + }); + + test('each invocation starts a distinct trace', () { + final hub = fixture.getSut(); + + final first = hub.startNewTrace(() => hub.startTransaction('a', 'op')); + final second = hub.startNewTrace(() => hub.startTransaction('b', 'op')); + + expect(first.context.traceId, isNot(second.context.traceId)); + }); + + test('nested calls start another new trace', () { + final hub = fixture.getSut(); + + final (outer, inner) = hub.startNewTrace(() { + final outer = hub.traceScope.propagationContext.traceId; + final inner = hub.startNewTrace( + () => hub.traceScope.propagationContext.traceId, + ); + return (outer, inner); + }); + + expect(outer, isNot(inner)); + }); + + test('streaming spans inside callback use the new trace id', () async { + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.stream); + final outerTraceId = hub.scope.propagationContext.traceId; + + final traceId = await hub.startNewTrace( + () => hub.startSpan('name', (span) async => span.traceId), + ); + + expect(traceId, isNot(outerTraceId)); + expect(hub.scope.propagationContext.traceId, outerTraceId); + }); + + test('captured events inside callback carry the new trace id', () async { + final hub = fixture.getSut(); + final outerTraceId = hub.scope.propagationContext.traceId; + + await hub.startNewTrace(() => hub.captureEvent(SentryEvent())); + + final capturedEvent = fixture.client.captureEventCalls.first; + final capturedTraceId = capturedEvent.scope?.propagationContext.traceId; + expect(capturedTraceId, isNotNull); + expect(capturedTraceId, isNot(outerTraceId)); + expect(hub.scope.propagationContext.traceId, outerTraceId); + }); + + test('captured events outside callback keep the hub trace id', () async { + final hub = fixture.getSut(); + final outerTraceId = hub.scope.propagationContext.traceId; + + await hub.captureEvent(SentryEvent()); + + final capturedEvent = fixture.client.captureEventCalls.first; + expect(capturedEvent.scope?.propagationContext.traceId, outerTraceId); + }); + + test('rethrows errors from the callback', () { + final hub = fixture.getSut(); + + expect( + () => hub.startNewTrace(() => throw StateError('boom')), + throwsStateError, + ); + }); + }); + group('Hub scope callback', () { late Fixture fixture; late SentryEvent fakeEvent; diff --git a/packages/dart/test/sentry_test.dart b/packages/dart/test/sentry_test.dart index 0d54cd3fa5..7b9797f7e0 100644 --- a/packages/dart/test/sentry_test.dart +++ b/packages/dart/test/sentry_test.dart @@ -52,6 +52,22 @@ void main() { expect(client.captureEventCalls.first.scope, isNotNull); }); + test('startNewTrace starts a trace distinct from the current scope', + () async { + final outerTraceId = Sentry.currentHub.scope.propagationContext.traceId; + + final transaction = await Sentry.startNewTrace(() async { + await Future.delayed(Duration.zero); + return Sentry.startTransaction('name', 'op'); + }); + + expect(transaction.context.traceId, isNot(outerTraceId)); + expect( + Sentry.currentHub.scope.propagationContext.traceId, + outerTraceId, + ); + }); + test('should capture the feedback event', () async { final fakeFeedback = SentryFeedback(message: 'message'); await Sentry.captureFeedback(fakeFeedback); diff --git a/packages/dart/test/telemetry/span/propagation/tracing_utils_test.dart b/packages/dart/test/telemetry/span/propagation/tracing_utils_test.dart index c397d2dfa5..170f3257ea 100644 --- a/packages/dart/test/telemetry/span/propagation/tracing_utils_test.dart +++ b/packages/dart/test/telemetry/span/propagation/tracing_utils_test.dart @@ -372,6 +372,21 @@ void main() { expect(traceHeader.traceId, hub.scope.propagationContext.traceId); expect(headers['baggage'], contains('test=value')); }); + + test('adds headers from the new trace inside startNewTrace', () { + final headers = {}; + final hub = fixture._hub; + final outerTraceId = hub.scope.propagationContext.traceId; + + hub.startNewTrace(() { + addTracingHeadersToHttpHeader(headers, hub); + }); + + final traceHeader = + SentryTraceHeader.fromTraceHeader(headers['sentry-trace']); + expect(traceHeader.traceId, isNot(outerTraceId)); + expect(traceHeader.traceId, isNotNull); + }); }); group('$addSentryTraceHeaderFromScope', () { diff --git a/packages/dio/test/mocks/mock_hub.dart b/packages/dio/test/mocks/mock_hub.dart index 294192d6b8..d9526a21fe 100644 --- a/packages/dio/test/mocks/mock_hub.dart +++ b/packages/dio/test/mocks/mock_hub.dart @@ -20,6 +20,9 @@ class MockHub with NoSuchMethodProvider implements Hub { @override Scope get scope => Scope(_options); + @override + Scope get traceScope => scope; + final _options = defaultTestOptions(); @override From 240f68b82559e7c75fd6195061e6658616eb9df7 Mon Sep 17 00:00:00 2001 From: Hameed Habeeblahi Gbolahan Date: Wed, 23 Sep 2026 18:34:44 +0100 Subject: [PATCH 2/6] fix(tracing): Isolate startNewTrace from outer span refs Cursor Bugbot and Seer flagged that startNewTrace let the outer trace leak into the callback through three span references on the hub scope: - Scope.clone copied _activeSpan onto the forked scope, so getActiveSpan still returned an outer span. - getActiveSpan fell back to _currentIdleSpan unconditionally, so nested startSpan calls parented onto the outer idle span and inherited its trace id (Flutter idle-span case). - getSpan and Scope.applyToEvent read the hub scope's bound transaction, so getSpan(), HTTP header propagation and captured events inside the callback stayed on the outer trace (Flutter navigation-transaction case). Add a Zone sentinel _newTraceKey set alongside _scopeKey in startNewTrace. Inside that zone: - Also clearActiveSpan() on the forked scope. - getActiveSpan skips the _currentIdleSpan fallback. - getSpan reads from the zone-forked scope, not the hub scope. - _cloneAndRunWithScope clears the incoming scope's span and active span, so applyToEvent derives trace context from the fresh PropagationContext instead of an outer span. Three new tests cover: idle span active on hub, hub-bound transaction with an event captured inside the callback, and getSpan() inside the callback. Refs #3930 --- packages/dart/lib/src/hub.dart | 57 ++++++++++++++++++++++++-------- packages/dart/lib/src/scope.dart | 7 ++++ packages/dart/test/hub_test.dart | 47 ++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index bf8ecd9011..1949f96892 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -345,11 +345,21 @@ class Hub { // Inside a `startNewTrace` callback the zone-forked scope carries a // different propagation context than the hub's scope. Use it so that // events captured within the callback belong to the new trace, without - // mutating the hub's scope. - final zoneScope = _zoneScope; - if (zoneScope != null && - !identical(zoneScope.propagationContext, scope.propagationContext)) { - scope = scope.clone()..propagationContext = zoneScope.propagationContext; + // mutating the hub's scope. Also clear the incoming scope's bound + // transaction and active span so `applyToEvent` doesn't derive the + // trace context from an outer span (e.g. a Flutter navigation + // transaction bound to the hub scope). + if (_isInNewTraceZone) { + final zoneScope = _zoneScope; + scope = scope.clone() + ..propagationContext = + zoneScope?.propagationContext ?? scope.propagationContext + ..span = zoneScope?.span + ..clearActiveSpan(); + final zoneActive = zoneScope?.getActiveSpan(); + if (zoneActive != null) { + scope.setActiveSpan(zoneActive); + } } if (withScope != null) { try { @@ -711,6 +721,14 @@ class Hub { /// `Zone.current[_scopeKey]` therefore gives the most recently pushed scope. Scope? get _zoneScope => Zone.current[_scopeKey] as Scope?; + static final _newTraceKey = Object(); + + /// True when the current zone was established by [startNewTrace] and the + /// callback is still executing. Signals that the hub's outer span + /// references (bound transaction, active span, idle span) must not leak + /// into the isolated trace. + bool get _isInNewTraceZone => Zone.current[_newTraceKey] == true; + /// The [Scope] whose [PropagationContext] governs the current trace. /// /// Inside a [startNewTrace] callback this is the zone-forked scope carrying @@ -738,8 +756,12 @@ class Hub { } final forkedScope = (_zoneScope ?? scope).clone() ..propagationContext = PropagationContext() - ..span = null; - return runZoned(callback, zoneValues: {_scopeKey: forkedScope}); + ..span = null + ..clearActiveSpan(); + return runZoned( + callback, + zoneValues: {_scopeKey: forkedScope, _newTraceKey: true}, + ); } /// Returns the currently active span, or `null` if no span is in progress. @@ -748,11 +770,17 @@ class Hub { /// 1. The active span on the zone-forked scope ([_zoneScope]) — this is set /// when code is running inside a [startSpan] or [startSpanSync] callback. /// 2. The hub-level idle span ([_currentIdleSpan]) — present when an idle - /// span has been started and has not yet ended. + /// span has been started and has not yet ended. Skipped when the current + /// zone was established by [startNewTrace], so the isolated trace does + /// not inherit an outer idle span as parent. /// 3. `null` — no span is active. @internal - RecordingSentrySpanV2? getActiveSpan() => - _zoneScope?.getActiveSpan() ?? _currentIdleSpan; + RecordingSentrySpanV2? getActiveSpan() { + final zoneSpan = _zoneScope?.getActiveSpan(); + if (zoneSpan != null) return zoneSpan; + if (_isInNewTraceZone) return null; + return _currentIdleSpan; + } Future startSpan( String name, @@ -1126,9 +1154,12 @@ class Hub { "Instance is disabled and this 'getSpan' call is a no-op.", ); } else if (_options.isTracingEnabled()) { - final item = _peek(); - - span = item.scope.span; + if (_isInNewTraceZone) { + span = _zoneScope?.span; + } else { + final item = _peek(); + span = item.scope.span; + } } return span; diff --git a/packages/dart/lib/src/scope.dart b/packages/dart/lib/src/scope.dart index 0a6d149831..0643e1031a 100644 --- a/packages/dart/lib/src/scope.dart +++ b/packages/dart/lib/src/scope.dart @@ -77,6 +77,13 @@ class Scope { } } + /// Unconditionally clears the active span. Used when isolating a scope + /// from its parent, e.g. inside a `startNewTrace` callback. + @internal + void clearActiveSpan() { + _activeSpan = null; + } + /// The propagation context for connecting errors and spans to traces. /// There should always be a propagation context available at all times. /// diff --git a/packages/dart/test/hub_test.dart b/packages/dart/test/hub_test.dart index 734442a689..b58ca9a341 100644 --- a/packages/dart/test/hub_test.dart +++ b/packages/dart/test/hub_test.dart @@ -1202,6 +1202,53 @@ void main() { throwsStateError, ); }); + + test( + 'streaming spans inside callback do not inherit an outer idle span ' + 'as parent', () async { + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.stream); + final idle = hub.startIdleSpan('outer-idle'); + addTearDown(() async => idle.end()); + final outerIdleTraceId = idle.traceId; + + final innerTraceId = await hub.startNewTrace( + () => hub.startSpan('inner', (span) async => span.traceId), + ); + + expect(innerTraceId, isNot(outerIdleTraceId)); + }); + + test( + 'events captured inside callback ignore a transaction bound to the ' + 'hub scope', () async { + final hub = fixture.getSut(); + hub.startTransaction('nav', 'navigation', bindToScope: true); + final boundTraceId = hub.scope.span!.context.traceId; + + final innerPropagationTraceId = await hub.startNewTrace(() async { + await hub.captureEvent(SentryEvent()); + // Return the propagation-context trace id from inside the zone so + // the assertion below has something concrete to compare against. + return hub.traceScope.propagationContext.traceId; + }); + + expect(innerPropagationTraceId, isNot(boundTraceId)); + final capturedEvent = fixture.client.captureEventCalls.first; + final capturedTraceId = capturedEvent.scope?.propagationContext.traceId; + expect(capturedTraceId, innerPropagationTraceId); + expect(capturedTraceId, isNot(boundTraceId)); + }); + + test('getSpan inside callback does not return the hub-bound transaction', + () { + final hub = fixture.getSut(); + hub.startTransaction('nav', 'navigation', bindToScope: true); + final outsideSpan = hub.getSpan(); + expect(outsideSpan, isNotNull); + + final insideSpan = hub.startNewTrace(() => hub.getSpan()); + expect(insideSpan, isNull); + }); }); group('Hub scope callback', () { From 2c191df1bbd3a5347403a26dbe304ef1d3b97093 Mon Sep 17 00:00:00 2001 From: Hameed Habeeblahi Gbolahan Date: Thu, 24 Sep 2026 06:44:46 +0100 Subject: [PATCH 3/6] fix(tracing): Route bindToScope through zone scope in new trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot flagged that startTransaction(bindToScope: true) inside a startNewTrace callback wrote the tracer to the hub's scope stack, so the previous fixup — which taught getSpan and applyToEvent to read from the zone-forked scope — no longer saw it. Errors and outgoing HTTP requests inside the callback would not attach to that transaction, and the write leaked into the hub scope for code running outside. Route the bindToScope write to the zone-forked scope when the current zone was established by startNewTrace, so the bound transaction is visible for the duration of the callback and the hub scope stays untouched. Refs #3930 --- packages/dart/lib/src/hub.dart | 8 +++++++- packages/dart/test/hub_test.dart | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index 1949f96892..7bc404b702 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -701,7 +701,13 @@ class Hub { onFinish: onFinish, ); if (bindToScope ?? false) { - item.scope.span = tracer; + // Inside a `startNewTrace` callback, bind to the zone-forked scope + // so the transaction is visible to `getSpan`, HTTP auto- + // instrumentation and captured events for the duration of the + // callback, without leaking into the hub's scope. + final bindTarget = + _isInNewTraceZone ? (_zoneScope ?? item.scope) : item.scope; + bindTarget.span = tracer; } return tracer; diff --git a/packages/dart/test/hub_test.dart b/packages/dart/test/hub_test.dart index b58ca9a341..9fde0b2518 100644 --- a/packages/dart/test/hub_test.dart +++ b/packages/dart/test/hub_test.dart @@ -1249,6 +1249,31 @@ void main() { final insideSpan = hub.startNewTrace(() => hub.getSpan()); expect(insideSpan, isNull); }); + + test( + 'startTransaction(bindToScope: true) inside callback is visible to ' + 'getSpan and captured events, and does not leak to the hub scope', + () async { + final hub = fixture.getSut(); + + final (boundTx, spanFromGetSpan) = await hub.startNewTrace(() async { + final tx = hub.startTransaction('inner', 'op', bindToScope: true); + final observed = hub.getSpan(); + await hub.captureEvent(SentryEvent()); + return (tx, observed); + }); + + expect(spanFromGetSpan, same(boundTx)); + + // The scope passed to the client carries the bound transaction, so + // `applyToEvent` will derive the event's trace context from it. + final capturedScope = fixture.client.captureEventCalls.first.scope; + expect(capturedScope?.span, same(boundTx)); + + // The transaction lived on the zone-forked scope, so the hub scope + // is untouched once the callback exits. + expect(hub.scope.span, isNull); + }); }); group('Hub scope callback', () { From 2c94a73d874e5b2211d741664e8cafceccb9a86d Mon Sep 17 00:00:00 2001 From: Hameed Habeeblahi Gbolahan Date: Thu, 24 Sep 2026 07:05:16 +0100 Subject: [PATCH 4/6] fix(tracing): Route logs and metrics through traceScope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot flagged that structured logs and metrics still stamped the outer trace id when emitted inside a startNewTrace callback: DefaultSentryLogger and DefaultSentryMetrics build the log/metric synchronously (before hub.captureLog/captureMetric runs) and read propagationContext from the scope provider — which was wired to hub.scope, not the zone-forked scope. Wire the scope provider to hub.traceScope in both setup integrations, so the logger and metrics implementations pick up the callback-scoped propagation context inside startNewTrace and fall back to the hub scope everywhere else. Refs #3930 --- packages/dart/lib/src/hub.dart | 8 +- .../telemetry/telemetry_trace_context.dart | 5 +- packages/dart/test/hub_test.dart | 111 ++++++++++++------ 3 files changed, 84 insertions(+), 40 deletions(-) diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index 7bc404b702..64c8e3e1c9 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -705,8 +705,9 @@ class Hub { // so the transaction is visible to `getSpan`, HTTP auto- // instrumentation and captured events for the duration of the // callback, without leaking into the hub's scope. - final bindTarget = - _isInNewTraceZone ? (_zoneScope ?? item.scope) : item.scope; + final bindTarget = _isInNewTraceZone + ? (_zoneScope ?? item.scope) + : item.scope; bindTarget.span = tracer; } @@ -754,8 +755,7 @@ class Hub { /// `await`s for asynchronous callbacks. T startNewTrace(T Function() callback) { if (!_isEnabled) { - _options.log( - SentryLevel.warning, + internalLogger.warning( "Instance is disabled and this 'startNewTrace' call is a no-op.", ); return callback(); diff --git a/packages/dart/lib/src/telemetry/telemetry_trace_context.dart b/packages/dart/lib/src/telemetry/telemetry_trace_context.dart index f99ca9de89..bc3bee0d0f 100644 --- a/packages/dart/lib/src/telemetry/telemetry_trace_context.dart +++ b/packages/dart/lib/src/telemetry/telemetry_trace_context.dart @@ -13,8 +13,11 @@ typedef TelemetryTraceContextProvider = TelemetryTraceContext Function(); /// scope or the hub's idle span and is therefore not visible through /// [Hub.scope]. In static mode it is the transaction bound to the scope. /// Without a span, only the propagation context's trace is used. +/// +/// Uses [Hub.traceScope] so that logs and metrics emitted inside a +/// `startNewTrace` callback are stamped with the new trace id, not the hub's. TelemetryTraceContext resolveTelemetryTraceContext(Hub hub) { - final scope = hub.scope; + final scope = hub.traceScope; if (hub.options.traceLifecycle == SentryTraceLifecycle.stream) { final activeSpan = hub.getActiveSpan(); return ( diff --git a/packages/dart/test/hub_test.dart b/packages/dart/test/hub_test.dart index 9fde0b2518..bc53d2323e 100644 --- a/packages/dart/test/hub_test.dart +++ b/packages/dart/test/hub_test.dart @@ -1,6 +1,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/client_reports/discard_reason.dart'; +import 'package:sentry/src/telemetry/log/logger_setup_integration.dart'; +import 'package:sentry/src/telemetry/metric/metrics_setup_integration.dart'; import 'package:sentry/src/telemetry/span/propagation/propagation_context.dart'; import 'package:sentry/src/telemetry/span/transaction/sentry_tracer.dart'; import 'package:sentry/src/transport/data_category.dart'; @@ -1111,10 +1113,12 @@ void main() { test('transactions inside the same callback share one trace id', () { final hub = fixture.getSut(); - final (first, second) = hub.startNewTrace(() => ( - hub.startTransaction('first', 'op'), - hub.startTransaction('second', 'op'), - )); + final (first, second) = hub.startNewTrace( + () => ( + hub.startTransaction('first', 'op'), + hub.startTransaction('second', 'op'), + ), + ); expect(first.context.traceId, second.context.traceId); }); @@ -1137,7 +1141,7 @@ void main() { }); test('each invocation starts a distinct trace', () { - final hub = fixture.getSut(); + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.static); final first = hub.startNewTrace(() => hub.startTransaction('a', 'op')); final second = hub.startNewTrace(() => hub.startTransaction('b', 'op')); @@ -1203,8 +1207,7 @@ void main() { ); }); - test( - 'streaming spans inside callback do not inherit an outer idle span ' + test('streaming spans inside callback do not inherit an outer idle span ' 'as parent', () async { final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.stream); final idle = hub.startIdleSpan('outer-idle'); @@ -1218,10 +1221,9 @@ void main() { expect(innerTraceId, isNot(outerIdleTraceId)); }); - test( - 'events captured inside callback ignore a transaction bound to the ' + test('events captured inside callback ignore a transaction bound to the ' 'hub scope', () async { - final hub = fixture.getSut(); + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.static); hub.startTransaction('nav', 'navigation', bindToScope: true); final boundTraceId = hub.scope.span!.context.traceId; @@ -1239,41 +1241,80 @@ void main() { expect(capturedTraceId, isNot(boundTraceId)); }); - test('getSpan inside callback does not return the hub-bound transaction', - () { + test( + 'getSpan inside callback does not return the hub-bound transaction', + () { + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.static); + hub.startTransaction('nav', 'navigation', bindToScope: true); + final outsideSpan = hub.getSpan(); + expect(outsideSpan, isNotNull); + + final insideSpan = hub.startNewTrace(() => hub.getSpan()); + expect(insideSpan, isNull); + }, + ); + + test('structured logs inside callback carry the new trace id', () async { final hub = fixture.getSut(); - hub.startTransaction('nav', 'navigation', bindToScope: true); - final outsideSpan = hub.getSpan(); - expect(outsideSpan, isNotNull); + LoggerSetupIntegration().call(hub, fixture.options); + final outerTraceId = hub.scope.propagationContext.traceId; - final insideSpan = hub.startNewTrace(() => hub.getSpan()); - expect(insideSpan, isNull); + final zoneTraceId = hub.startNewTrace(() { + fixture.options.logger.info('inside'); + return hub.traceScope.propagationContext.traceId; + }); + // `info` fire-and-forgets through captureLog, so let microtasks flush. + await Future.delayed(Duration.zero); + + expect(zoneTraceId, isNot(outerTraceId)); + final capturedLog = fixture.client.captureLogCalls.first.log; + expect(capturedLog.traceId, zoneTraceId); + expect(capturedLog.traceId, isNot(outerTraceId)); }); - test( - 'startTransaction(bindToScope: true) inside callback is visible to ' - 'getSpan and captured events, and does not leak to the hub scope', - () async { + test('metrics inside callback carry the new trace id', () async { final hub = fixture.getSut(); + MetricsSetupIntegration().call(hub, fixture.options); + final outerTraceId = hub.scope.propagationContext.traceId; - final (boundTx, spanFromGetSpan) = await hub.startNewTrace(() async { - final tx = hub.startTransaction('inner', 'op', bindToScope: true); - final observed = hub.getSpan(); - await hub.captureEvent(SentryEvent()); - return (tx, observed); + final zoneTraceId = hub.startNewTrace(() { + fixture.options.metrics.count('inside', 1); + return hub.traceScope.propagationContext.traceId; }); + // `count` fire-and-forgets, so let the microtask flush. + await Future.delayed(Duration.zero); - expect(spanFromGetSpan, same(boundTx)); + expect(zoneTraceId, isNot(outerTraceId)); + final capturedMetric = fixture.client.captureMetricCalls.first.metric; + expect(capturedMetric.traceId, zoneTraceId); + expect(capturedMetric.traceId, isNot(outerTraceId)); + }); - // The scope passed to the client carries the bound transaction, so - // `applyToEvent` will derive the event's trace context from it. - final capturedScope = fixture.client.captureEventCalls.first.scope; - expect(capturedScope?.span, same(boundTx)); + test( + 'startTransaction(bindToScope: true) inside callback is visible to ' + 'getSpan and captured events, and does not leak to the hub scope', + () async { + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.static); - // The transaction lived on the zone-forked scope, so the hub scope - // is untouched once the callback exits. - expect(hub.scope.span, isNull); - }); + final (boundTx, spanFromGetSpan) = await hub.startNewTrace(() async { + final tx = hub.startTransaction('inner', 'op', bindToScope: true); + final observed = hub.getSpan(); + await hub.captureEvent(SentryEvent()); + return (tx, observed); + }); + + expect(spanFromGetSpan, same(boundTx)); + + // The scope passed to the client carries the bound transaction, so + // `applyToEvent` will derive the event's trace context from it. + final capturedScope = fixture.client.captureEventCalls.first.scope; + expect(capturedScope?.span, same(boundTx)); + + // The transaction lived on the zone-forked scope, so the hub scope + // is untouched once the callback exits. + expect(hub.scope.span, isNull); + }, + ); }); group('Hub scope callback', () { From 58b7cb88223f4d7058b1296e28f51a3c06a17880 Mon Sep 17 00:00:00 2001 From: Hameed Habeeblahi Gbolahan Date: Thu, 15 Oct 2026 05:42:05 +0100 Subject: [PATCH 5/6] fix(tracing): Route markActiveSpanAsErrored through traceScope Bugbot flagged that in static mode, markActiveSpanAsErrored used configureScope which only reaches the hub scope. A transaction bound inside a startNewTrace callback lives on the zone-forked scope, so an unhandled error silently failed to mark it as errored while the outer hub span stayed clean. Read the span from traceScope directly, same pattern the rest of startNewTrace already uses for getSpan and event capture. Refs #3930 --- .../exception/unhandled_error_span_status.dart | 7 ++++--- packages/dart/test/hub_test.dart | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/dart/lib/src/exception/unhandled_error_span_status.dart b/packages/dart/lib/src/exception/unhandled_error_span_status.dart index ed93851f07..6dbfeeafd8 100644 --- a/packages/dart/lib/src/exception/unhandled_error_span_status.dart +++ b/packages/dart/lib/src/exception/unhandled_error_span_status.dart @@ -15,9 +15,10 @@ extension UnhandledErrorSpanStatus on Hub { if (options.traceLifecycle == SentryTraceLifecycle.stream) { getActiveSpan()?.status = SentrySpanStatusV2.error; } else { - configureScope( - (scope) => scope.span?.status ??= const SpanStatus.internalError(), - ); + // Use `traceScope` so that a transaction bound inside a + // `startNewTrace` callback (which lives on the zone-forked scope) + // is reached — not the hub scope that `configureScope` would see. + traceScope.span?.status ??= const SpanStatus.internalError(); } } } diff --git a/packages/dart/test/hub_test.dart b/packages/dart/test/hub_test.dart index bc53d2323e..8509f43765 100644 --- a/packages/dart/test/hub_test.dart +++ b/packages/dart/test/hub_test.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/client_reports/discard_reason.dart'; +import 'package:sentry/src/exception/unhandled_error_span_status.dart'; import 'package:sentry/src/telemetry/log/logger_setup_integration.dart'; import 'package:sentry/src/telemetry/metric/metrics_setup_integration.dart'; import 'package:sentry/src/telemetry/span/propagation/propagation_context.dart'; @@ -1290,6 +1291,22 @@ void main() { expect(capturedMetric.traceId, isNot(outerTraceId)); }); + test( + 'markActiveSpanAsErrored reaches a transaction bound inside the callback', + () { + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.static); + + final boundTx = hub.startNewTrace(() { + final tx = hub.startTransaction('inner', 'op', bindToScope: true); + hub.markActiveSpanAsErrored(); + return tx; + }); + + expect(boundTx.status, const SpanStatus.internalError()); + expect(hub.scope.span, isNull); + }, + ); + test( 'startTransaction(bindToScope: true) inside callback is visible to ' 'getSpan and captured events, and does not leak to the hub scope', From 5cc75596958c26505ab2bafdfd700a627e45b2a0 Mon Sep 17 00:00:00 2001 From: Hameed Habeeblahi Gbolahan Date: Sat, 31 Oct 2026 07:59:02 +0000 Subject: [PATCH 6/6] fix(tracing): Scope bound tx cleanup and idle spans in startNewTrace Two leaks flagged by @buenaflor: 1. A transaction bound inside startNewTrace via bindToScope was written to the zone-forked scope, but SentryTracer.finish went through configureScope which only reaches the hub scope. The finished tx stayed on the zone scope and getSpan kept returning it. Add Hub.unbindSpanFromScope that clears from either scope and call it from finish instead of configureScope. 2. startIdleSpan always wrote to the hub-level _idleSpan, so an idle span started inside the callback (which was invisible inside, since getActiveSpan skips the idle-span fallback in a new-trace zone) became active for all code outside once the callback exited. Store the idle span in a zone-local holder when inside startNewTrace, and have _currentIdleSpan read from the holder in that zone. Regression tests for both. Refs #3930 --- .../_sentry_testing/lib/src/mock_hub.dart | 3 ++ packages/dart/lib/src/hub.dart | 52 +++++++++++++++++-- packages/dart/lib/src/hub_adapter.dart | 4 ++ packages/dart/lib/src/noop_hub.dart | 3 ++ .../span/transaction/sentry_tracer.dart | 9 ++-- packages/dart/test/hub_test.dart | 26 ++++++++++ packages/dio/test/mocks/mock_hub.dart | 3 ++ 7 files changed, 90 insertions(+), 10 deletions(-) diff --git a/packages/_sentry_testing/lib/src/mock_hub.dart b/packages/_sentry_testing/lib/src/mock_hub.dart index 7245f2b996..b9e35239dc 100644 --- a/packages/_sentry_testing/lib/src/mock_hub.dart +++ b/packages/_sentry_testing/lib/src/mock_hub.dart @@ -169,6 +169,9 @@ class MockHub with NoSuchMethodProvider implements Hub { @override Scope get traceScope => scope; + + @override + void unbindSpanFromScope(ISentrySpan span) {} } class CaptureEventCall { diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index 64c8e3e1c9..75809a958a 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -42,11 +42,18 @@ class Hub { late final _WeakMap _throwableToSpan; - IdleRecordingSentrySpanV2? get _currentIdleSpan => - _idleSpan?.isEnded == false ? _idleSpan : null; + /// The active idle span, scoped to the current zone if we're inside a + /// [startNewTrace] callback, otherwise hub-wide. + IdleRecordingSentrySpanV2? get _currentIdleSpan { + final span = _isInNewTraceZone ? _zoneIdleSpanHolder?.span : _idleSpan; + return span?.isEnded == false ? span : null; + } IdleRecordingSentrySpanV2? _idleSpan; + _IdleSpanHolder? get _zoneIdleSpanHolder => + Zone.current[_idleSpanHolderKey] as _IdleSpanHolder?; + factory Hub(SentryOptions options) { _validateOptions(options); @@ -730,6 +737,8 @@ class Hub { static final _newTraceKey = Object(); + static final _idleSpanHolderKey = Object(); + /// True when the current zone was established by [startNewTrace] and the /// callback is still executing. Signals that the hub's outer span /// references (bound transaction, active span, idle span) must not leak @@ -743,6 +752,24 @@ class Hub { @internal Scope get traceScope => _zoneScope ?? scope; + /// Clears [span] from whichever scope currently holds it as `scope.span`. + /// + /// Checks both the zone-forked scope (where a transaction may have been + /// bound inside a [startNewTrace] callback) and the hub scope. Called by + /// transaction `finish()` so a bound tx is unbound regardless of which + /// scope it was bound to. + @internal + void unbindSpanFromScope(ISentrySpan span) { + final zoneScope = _zoneScope; + if (zoneScope != null && zoneScope.span == span) { + zoneScope.span = null; + } + final hubScope = scope; + if (hubScope.span == span) { + hubScope.span = null; + } + } + /// Starts a new trace and runs [callback] inside it. /// /// Everything started within [callback] — transactions, spans and captured @@ -766,7 +793,11 @@ class Hub { ..clearActiveSpan(); return runZoned( callback, - zoneValues: {_scopeKey: forkedScope, _newTraceKey: true}, + zoneValues: { + _scopeKey: forkedScope, + _newTraceKey: true, + _idleSpanHolderKey: _IdleSpanHolder(), + }, ); } @@ -1106,7 +1137,15 @@ class Hub { } if (bindToHub) { - _idleSpan = span; + // Inside a startNewTrace zone, bind to the zone-local holder so the + // idle span stays scoped to the callback and doesn't leak to the hub + // after it exits. + final holder = _zoneIdleSpanHolder; + if (holder != null) { + holder.span = span; + } else { + _idleSpan = span; + } } _options.lifecycleRegistry.dispatchCallback(OnSpanStartV2(span)); @@ -1266,6 +1305,11 @@ class _StackItem { _StackItem(this.client, this.scope); } +/// Mutable holder for an idle span scoped to a `startNewTrace` zone. +class _IdleSpanHolder { + IdleRecordingSentrySpanV2? span; +} + class _WeakMap { final _expando = Expando(); diff --git a/packages/dart/lib/src/hub_adapter.dart b/packages/dart/lib/src/hub_adapter.dart index acbad9b6fd..612ac55996 100644 --- a/packages/dart/lib/src/hub_adapter.dart +++ b/packages/dart/lib/src/hub_adapter.dart @@ -184,6 +184,10 @@ class HubAdapter implements Hub { @override Scope get traceScope => Sentry.currentHub.traceScope; + @override + void unbindSpanFromScope(ISentrySpan span) => + Sentry.currentHub.unbindSpanFromScope(span); + @override void setSpanContext( dynamic throwable, diff --git a/packages/dart/lib/src/noop_hub.dart b/packages/dart/lib/src/noop_hub.dart index d54487c382..d6fc983df9 100644 --- a/packages/dart/lib/src/noop_hub.dart +++ b/packages/dart/lib/src/noop_hub.dart @@ -135,6 +135,9 @@ class NoOpHub implements Hub { @override Scope get traceScope => scope; + @override + void unbindSpanFromScope(ISentrySpan span) {} + @override ISentrySpan? getSpan() => null; diff --git a/packages/dart/lib/src/telemetry/span/transaction/sentry_tracer.dart b/packages/dart/lib/src/telemetry/span/transaction/sentry_tracer.dart index 7aa0a48e4c..c0c23fcf8c 100644 --- a/packages/dart/lib/src/telemetry/span/transaction/sentry_tracer.dart +++ b/packages/dart/lib/src/telemetry/span/transaction/sentry_tracer.dart @@ -139,12 +139,9 @@ class SentryTracer extends ISentrySpan { } await _rootSpan.finish(endTimestamp: _rootEndTimestamp, hint: hint); - // remove from scope - await _hub.configureScope((scope) { - if (scope.span == this) { - scope.span = null; - } - }); + // remove from scope — checks both hub scope and zone-forked scope so + // a transaction bound inside a startNewTrace callback is cleared too. + _hub.unbindSpanFromScope(this); // if it's an idle transaction which has no children, we drop it to save user's quota if (children.isEmpty && _autoFinishAfter != null) { diff --git a/packages/dart/test/hub_test.dart b/packages/dart/test/hub_test.dart index 8509f43765..692fd314cb 100644 --- a/packages/dart/test/hub_test.dart +++ b/packages/dart/test/hub_test.dart @@ -1307,6 +1307,32 @@ void main() { }, ); + test('finish on a transaction bound inside callback unbinds it from the ' + 'zone scope so getSpan stops returning it', () async { + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.static); + + await hub.startNewTrace(() async { + final tx = hub.startTransaction('inner', 'op', bindToScope: true); + expect(hub.getSpan(), same(tx)); + await tx.finish(); + expect(hub.getSpan(), isNull); + }); + }); + + test( + 'idle spans started inside callback do not leak to the hub after exit', + () { + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.stream); + + hub.startNewTrace(() { + final idle = hub.startIdleSpan('inner-idle'); + addTearDown(() async => idle.end()); + }); + + expect(hub.getActiveSpan(), isNull); + }, + ); + test( 'startTransaction(bindToScope: true) inside callback is visible to ' 'getSpan and captured events, and does not leak to the hub scope', diff --git a/packages/dio/test/mocks/mock_hub.dart b/packages/dio/test/mocks/mock_hub.dart index d9526a21fe..f92f0da33c 100644 --- a/packages/dio/test/mocks/mock_hub.dart +++ b/packages/dio/test/mocks/mock_hub.dart @@ -23,6 +23,9 @@ class MockHub with NoSuchMethodProvider implements Hub { @override Scope get traceScope => scope; + @override + void unbindSpanFromScope(ISentrySpan span) {} + final _options = defaultTestOptions(); @override