diff --git a/packages/_sentry_testing/lib/src/mock_hub.dart b/packages/_sentry_testing/lib/src/mock_hub.dart index c148b4ab02..b9e35239dc 100644 --- a/packages/_sentry_testing/lib/src/mock_hub.dart +++ b/packages/_sentry_testing/lib/src/mock_hub.dart @@ -166,6 +166,12 @@ class MockHub with NoSuchMethodProvider implements Hub { @override Scope get scope => _scope; + + @override + Scope get traceScope => scope; + + @override + void unbindSpanFromScope(ISentrySpan span) {} } class CaptureEventCall { 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/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index cf258e7259..75809a958a 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'; @@ -41,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); @@ -341,6 +349,25 @@ 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. 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 { scope = scope.clone(); @@ -642,7 +669,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(); @@ -681,7 +708,14 @@ 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; @@ -701,17 +735,89 @@ class Hub { /// `Zone.current[_scopeKey]` therefore gives the most recently pushed scope. Scope? get _zoneScope => Zone.current[_scopeKey] as Scope?; + 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 + /// 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 + /// the new trace; otherwise it is the hub's current [scope]. + @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 + /// 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) { + internalLogger.warning( + "Instance is disabled and this 'startNewTrace' call is a no-op.", + ); + return callback(); + } + final forkedScope = (_zoneScope ?? scope).clone() + ..propagationContext = PropagationContext() + ..span = null + ..clearActiveSpan(); + return runZoned( + callback, + zoneValues: { + _scopeKey: forkedScope, + _newTraceKey: true, + _idleSpanHolderKey: _IdleSpanHolder(), + }, + ); + } + /// Returns the currently active span, or `null` if no span is in progress. /// /// Resolution order: /// 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, @@ -858,7 +964,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 +1057,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 +1119,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, @@ -1031,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)); @@ -1085,9 +1199,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; @@ -1188,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 93bfd86c65..612ac55996 100644 --- a/packages/dart/lib/src/hub_adapter.dart +++ b/packages/dart/lib/src/hub_adapter.dart @@ -177,6 +177,17 @@ 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 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 17bb66813a..d6fc983df9 100644 --- a/packages/dart/lib/src/noop_hub.dart +++ b/packages/dart/lib/src/noop_hub.dart @@ -129,6 +129,15 @@ class NoOpHub implements Hub { @override void generateNewTrace() {} + @override + T startNewTrace(T Function() callback) => callback(); + + @override + Scope get traceScope => scope; + + @override + void unbindSpanFromScope(ISentrySpan span) {} + @override ISentrySpan? getSpan() => null; 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/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/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/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 9f60c92043..692fd314cb 100644 --- a/packages/dart/test/hub_test.dart +++ b/packages/dart/test/hub_test.dart @@ -1,6 +1,9 @@ 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'; import 'package:sentry/src/telemetry/span/transaction/sentry_tracer.dart'; import 'package:sentry/src/transport/data_category.dart'; @@ -1038,6 +1041,325 @@ 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(traceLifecycle: SentryTraceLifecycle.static); + + 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, + ); + }); + + 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(traceLifecycle: SentryTraceLifecycle.static); + 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(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(); + LoggerSetupIntegration().call(hub, fixture.options); + final outerTraceId = hub.scope.propagationContext.traceId; + + 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('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 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(zoneTraceId, isNot(outerTraceId)); + final capturedMetric = fixture.client.captureMetricCalls.first.metric; + expect(capturedMetric.traceId, zoneTraceId); + 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('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', + () async { + final hub = fixture.getSut(traceLifecycle: SentryTraceLifecycle.static); + + 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', () { 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..f92f0da33c 100644 --- a/packages/dio/test/mocks/mock_hub.dart +++ b/packages/dio/test/mocks/mock_hub.dart @@ -20,6 +20,12 @@ class MockHub with NoSuchMethodProvider implements Hub { @override Scope get scope => Scope(_options); + @override + Scope get traceScope => scope; + + @override + void unbindSpanFromScope(ISentrySpan span) {} + final _options = defaultTestOptions(); @override