Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1ce8bf4
refactor(span): Update startSpan method signatures to return type T a…
buenaflor Mar 18, 2026
8f5bbec
feat(span): Introduce startSpanSync method for synchronous span creation
buenaflor Mar 18, 2026
26a48f3
test(span): Add missing critical tests for startSpan and startSpanSync
buenaflor Mar 18, 2026
967e251
build(flutter): Regenerate mocks for updated startSpan signature
buenaflor Mar 18, 2026
f8e4517
fix(span): Update spanV2Demo to use startSpanSync for synchronous spa…
buenaflor Mar 18, 2026
10a2306
fix analyze error
buenaflor Mar 18, 2026
33cf796
refactor(span): Simplify span error handling in Hub class
buenaflor Mar 18, 2026
f7e0ce3
refactor(span): Streamline runZoned usage in Hub class
buenaflor Mar 18, 2026
e803278
Potential fix for pull request finding
buenaflor Mar 18, 2026
3eba4e6
Potential fix for pull request finding
buenaflor Mar 18, 2026
8d7fadb
update
buenaflor Mar 18, 2026
9301c5a
Merge remote-tracking branch 'origin/feat/span/change-futureor' into …
buenaflor Mar 18, 2026
e534df8
Refactor getActiveSpan documentation comments
buenaflor Mar 19, 2026
7e10c39
docs(hub): Enhance documentation for scope and active span retrieval
buenaflor Mar 19, 2026
c73600f
refactor(span): Make startSpan asynchronous in Hub class
buenaflor Mar 19, 2026
2234aa8
docs(span): Enhance documentation and tests for startSpan and startSp…
buenaflor Mar 20, 2026
9be3678
fix(hub): Make startTimestamp parameter optional in NoOpHub's startSp…
buenaflor Mar 20, 2026
346681c
fix(hub): Remove async keyword from startSpanSync method in NoOpHub
buenaflor Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 76 additions & 52 deletions packages/dart/lib/src/hub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -627,30 +627,39 @@ class Hub {

static final _scopeKey = Object();

/// The scope forked by the innermost [startSpan] call on the current zone
/// chain, or `null` if we are outside any [startSpan] callback.
/// The [Scope] forked by the innermost [startSpan] or [startSpanSync] call
/// on the current zone chain, or `null` if the current zone has no
/// associated scope (i.e. we are outside any span callback).
///
/// Each call to [startSpan]/[startSpanSync] creates a new [Zone] with a
/// forked scope stored under [_scopeKey]. Walking up the zone chain via
/// `Zone.current[_scopeKey]` therefore gives the most recently pushed scope.
Scope? get _zoneScope => Zone.current[_scopeKey] as Scope?;

/// Returns the active span from the current zone's forked scope, or `null`
/// if no span is active.
/// Returns the currently active span, or `null` if no span is in progress.
///
/// Only zone-forked scopes (created by [startSpan]) carry an active span.
/// The hub's top-level scope is never mutated, so calling this outside a
/// [startSpan] callback always returns `null`.
/// 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.
/// 3. `null` — no span is active.
@internal
RecordingSentrySpanV2? getActiveSpan() =>
_zoneScope?.getActiveSpan() ?? _currentIdleSpan;

FutureOr<T> startSpan<T>(
Future<T> startSpan<T>(
String name,
FutureOr<T> Function(SentrySpanV2 span) callback, {
Future<T> Function(SentrySpanV2 span) callback, {
Map<String, SentryAttribute>? attributes,
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
}) {
DateTime? startTimestamp,
}) async {
final span = _createSpan(
name,
parentSpan: parentSpan,
attributes: attributes,
startTimestamp: startTimestamp,
);
switch (span) {
case NoOpSentrySpanV2():
Expand All @@ -662,52 +671,67 @@ class Hub {
'This is a bug — the sentinel should never leak out of _createSpan.',
);
return callback(span);
case RecordingSentrySpanV2():
break;
}

final parentScope = _zoneScope ?? scope;
final forkedScope = parentScope.clone()..setActiveSpan(span);

// Error handling is split into sync and async paths to preserve the
// FutureOr<T> return type — callers with a sync callback get a sync result.
//
// Note: We intentionally use runZoned() (not runZonedGuarded()) because
// the purpose is to propagate zone-local scope values, not to intercept
// errors. Using runZonedGuarded() would create an error-zone boundary
// that prevents errors from reaching the caller's await / catchError.
// The trade-off is that fire-and-forget async work inside the callback
// (e.g. unawaited futures, Timer.run) can throw errors that are not
// caught here — this is expected Dart zone behavior.
void endSpan({bool isError = false}) {
if (isError) span.status = SentrySpanStatusV2.error;
span.end();
forkedScope.removeActiveSpan(span);
}

FutureOr<T> result;
try {
result = runZoned(
() => callback(span),
zoneValues: {_scopeKey: forkedScope},
);
} catch (_) {
endSpan(isError: true);
rethrow;
case RecordingSentrySpanV2 recordingSpan:
final forkedScope = _forkScopeWithActiveSpan(recordingSpan);
try {
return await runZoned(
() => callback(recordingSpan),
zoneValues: {_scopeKey: forkedScope},
);
} catch (_) {
span.status = SentrySpanStatusV2.error;
rethrow;
} finally {
span.end();
forkedScope.removeActiveSpan(span);
}
}
}

if (result is Future<T>) {
return result.then((value) {
endSpan();
return value;
}, onError: (Object error, StackTrace stackTrace) {
endSpan(isError: true);
return Future<T>.error(error, stackTrace);
});
T startSpanSync<T>(
String name,
T Function(SentrySpanV2 span) callback, {
Map<String, SentryAttribute>? attributes,
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
DateTime? startTimestamp,
}) {
final span = _createSpan(
name,
parentSpan: parentSpan,
attributes: attributes,
startTimestamp: startTimestamp,
);
switch (span) {
case NoOpSentrySpanV2():
internalLogger
.info('Hub: startSpanSync returning no-op for \'$name\'.');
return callback(span);
case UnsetSentrySpanV2():
internalLogger.error(
'Hub: _createSpan returned UnsetSentrySpanV2 for \'$name\'. '
'This is a bug — the sentinel should never leak out of _createSpan.',
);
return callback(span);
case RecordingSentrySpanV2 recordingSpan:
final forkedScope = _forkScopeWithActiveSpan(recordingSpan);
try {
return runZoned(
() => callback(recordingSpan),
zoneValues: {_scopeKey: forkedScope},
);
} catch (_) {
span.status = SentrySpanStatusV2.error;
rethrow;
} finally {
span.end();
forkedScope.removeActiveSpan(span);
}
}
}

endSpan();
return result;
Scope _forkScopeWithActiveSpan(RecordingSentrySpanV2 span) {
final parentScope = _zoneScope ?? scope;
return parentScope.clone()..setActiveSpan(span);
}

SentrySpanV2 startInactiveSpan(
Expand Down
22 changes: 18 additions & 4 deletions packages/dart/lib/src/hub_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,26 @@ class HubAdapter implements Hub {
}

@override
FutureOr<T> startSpan<T>(
String name, FutureOr<T> Function(SentrySpanV2 span) callback,
Future<T> startSpan<T>(
String name, Future<T> Function(SentrySpanV2 span) callback,
{Map<String, SentryAttribute>? attributes,
SentrySpanV2? parentSpan = const UnsetSentrySpanV2()}) {
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
DateTime? startTimestamp}) {
return Sentry.currentHub.startSpan(name, callback,
attributes: attributes, parentSpan: parentSpan);
attributes: attributes,
parentSpan: parentSpan,
startTimestamp: startTimestamp);
}

@override
T startSpanSync<T>(String name, T Function(SentrySpanV2 span) callback,
{Map<String, SentryAttribute>? attributes,
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
DateTime? startTimestamp}) {
return Sentry.currentHub.startSpanSync(name, callback,
attributes: attributes,
parentSpan: parentSpan,
startTimestamp: startTimestamp);
}

@override
Expand Down
15 changes: 12 additions & 3 deletions packages/dart/lib/src/noop_hub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,19 @@ class NoOpHub implements Hub {
}

@override
FutureOr<T> startSpan<T>(
String name, FutureOr<T> Function(SentrySpanV2 span) callback,
Future<T> startSpan<T>(
String name, Future<T> Function(SentrySpanV2 span) callback,
{Map<String, SentryAttribute>? attributes,
SentrySpanV2? parentSpan = const UnsetSentrySpanV2()}) {
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
DateTime? startTimestamp}) {
return callback(NoOpSentrySpanV2.instance);
}

@override
T startSpanSync<T>(String name, T Function(SentrySpanV2 span) callback,
{Map<String, SentryAttribute>? attributes,
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
DateTime? startTimestamp}) {
return callback(NoOpSentrySpanV2.instance);
}

Expand Down
37 changes: 30 additions & 7 deletions packages/dart/lib/src/sentry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -387,13 +387,16 @@ class Sentry {
onFinish: onFinish,
);

/// Starts a new span, executes [callback], and ends the span automatically.
/// Starts a new span, executes an async [callback], and ends the span
/// automatically when the returned future completes.
///
/// The span is set as the active span within the [callback]'s scope via
/// zones, so any nested [startSpan] calls will automatically parent to it.
/// zones, so any nested [startSpan] or [startSpanSync] calls will
/// automatically parent to it.
///
/// If the [callback] throws or the returned future completes with an error,
/// the span's status is set to [SentrySpanStatusV2.error] before ending.
/// Use [startSpanSync] when the work completes synchronously.
///
/// By default, the span is created as a child of the currently active span.
/// Pass a [SentrySpanV2] as [parentSpan] to override the parent, or pass
Expand All @@ -411,14 +414,34 @@ class Sentry {
/// return orderService.create(cart, payment);
/// });
/// ```
static FutureOr<T> startSpan<T>(
static Future<T> startSpan<T>(
String name,
FutureOr<T> Function(SentrySpanV2 span) callback, {
Future<T> Function(SentrySpanV2 span) callback, {
Map<String, SentryAttribute>? attributes,
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
DateTime? startTimestamp,
}) =>
_hub.startSpan(name, callback,
attributes: attributes, parentSpan: parentSpan);
attributes: attributes,
parentSpan: parentSpan,
startTimestamp: startTimestamp);

/// Starts a new span, executes a synchronous [callback], and ends the span
/// before returning the callback result.
///
/// This is the synchronous variant of [startSpan]. Use [startSpan] when the
/// work is asynchronous.
static T startSpanSync<T>(
String name,
T Function(SentrySpanV2 span) callback, {
Map<String, SentryAttribute>? attributes,
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
DateTime? startTimestamp,
}) =>
_hub.startSpanSync(name, callback,
attributes: attributes,
parentSpan: parentSpan,
startTimestamp: startTimestamp);

/// Creates a span that is not set as the active span.
///
Expand All @@ -431,8 +454,8 @@ class Sentry {
/// Pass a [SentrySpanV2] as [parentSpan] to override the parent, or pass
/// `null` to create a root span.
///
/// Prefer [startSpan] when the work fits inside a single callback. Use
/// this method when the span must survive across execution boundaries
/// Prefer [startSpan] or [startSpanSync] when the work fits inside a single
/// callback. Use this method when the span must survive across execution boundaries
/// that a callback cannot wrap — for example, widget lifecycles, stream
/// subscriptions, or platform channel round-trips.
///
Expand Down
Loading
Loading