Skip to content
Open
6 changes: 6 additions & 0 deletions packages/_sentry_testing/lib/src/mock_hub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
154 changes: 138 additions & 16 deletions packages/dart/lib/src/hub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
if (withScope != null) {
try {
scope = scope.clone();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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>(T Function() callback) {
if (!_isEnabled) {
internalLogger.warning(
"Instance is disabled and this 'startNewTrace' call is a no-op.",
Comment thread
sentry[bot] marked this conversation as resolved.
Resolved
);
return callback();
}
final forkedScope = (_zoneScope ?? scope).clone()
..propagationContext = PropagationContext()
..span = null
..clearActiveSpan();
return runZoned(
callback,
zoneValues: {
_scopeKey: forkedScope,
_newTraceKey: true,
_idleSpanHolderKey: _IdleSpanHolder(),
},
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

/// 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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idle spans ignored in new traces

Medium Severity

getActiveSpan() returns null whenever _isInNewTraceZone is set, so it never consults _currentIdleSpan. That getter is already zone-aware and would return an idle span bound on _IdleSpanHolder without inheriting the hub's outer one. Child spans, HTTP auto-instrumentation, error status, and stream-mode logs or metrics inside startNewTrace therefore never parent to an idle span started in the callback.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot (Root)

Reviewed by Cursor Bugbot for commit 25b93e1. Configure here.


Future<T> startSpan<T>(
String name,
Expand Down Expand Up @@ -858,7 +964,7 @@ class Hub {
String name,
Map<String, SentryAttribute>? attributes,
) {
final propagationContext = scope.propagationContext;
final propagationContext = traceScope.propagationContext;
final sampleRand = propagationContext.sampleRand ??= Random().nextDouble();

final samplingContext = SentrySamplingContext.forSpanV2(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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));
Expand Down Expand Up @@ -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;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

return span;
Expand Down Expand Up @@ -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();

Expand Down
11 changes: 11 additions & 0 deletions packages/dart/lib/src/hub_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ class HubAdapter implements Hub {
@override
void generateNewTrace() => Sentry.currentHub.generateNewTrace();

@override
T startNewTrace<T>(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,
Expand Down
9 changes: 9 additions & 0 deletions packages/dart/lib/src/noop_hub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ class NoOpHub implements Hub {
@override
void generateNewTrace() {}

@override
T startNewTrace<T>(T Function() callback) => callback();

@override
Scope get traceScope => scope;

@override
void unbindSpanFromScope(ISentrySpan span) {}

@override
ISentrySpan? getSpan() => null;

Expand Down
7 changes: 7 additions & 0 deletions packages/dart/lib/src/scope.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
26 changes: 26 additions & 0 deletions packages/dart/lib/src/sentry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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>(T Function() callback) =>
_hub.startNewTrace(callback);

/// Starts a new span, executes a synchronous [callback], and ends the span
/// before returning the callback result.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading