Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
021260a
feat(span): Enhance span sampling and telemetry context handling
buenaflor Dec 30, 2025
9159d28
Merge branch 'feat/span-first' into feat/span/dsc
buenaflor Jan 7, 2026
aa1cbc3
refactor: rename dscFactory to dscCreator and update related implemen…
buenaflor Jan 7, 2026
c5288e5
feat: introduce span sampling context and lifecycle management
buenaflor Jan 7, 2026
967c93f
refactor: update span handling and sampling context management
buenaflor Jan 7, 2026
73ded7b
refactor: enhance SentrySamplingContext with dual-mode support
buenaflor Jan 7, 2026
f1c0e97
fix: update error types in SentrySamplingContext tests
buenaflor Jan 7, 2026
8dc4a53
fix: correct sampling decision references in SentryTraceContextHeader
buenaflor Jan 8, 2026
6581088
fix: update return statements in captureSpan method for consistency
buenaflor Jan 8, 2026
74057a6
fix: update captureSpan method to return void for consistency
buenaflor Jan 8, 2026
61c9219
fix: update sampling decision reference in trace context tests
buenaflor Jan 8, 2026
baba206
refactor: clean up unused imports and methods in trace context and te…
buenaflor Jan 8, 2026
37b7f7a
Rename DscCreator to DscCreatorCallback
buenaflor Jan 8, 2026
1e737da
Document attributes field in SentrySpanSamplingContextV2
buenaflor Jan 8, 2026
0f9bae8
refactor: simplify span creation in telemetry tests
buenaflor Jan 8, 2026
b425edd
fix: improve error handling in SentrySamplingContext
buenaflor Jan 8, 2026
621a0de
refactor: update SentryTraceContextHeader to accept options and replayId
buenaflor Jan 8, 2026
c5ede36
refactor: remove redundant StateError tests in SentrySamplingContext
buenaflor Jan 8, 2026
e997927
Formaatting
buenaflor Jan 8, 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
1 change: 1 addition & 0 deletions packages/dart/lib/sentry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export 'src/sdk_lifecycle_hooks.dart';
export 'src/sentry_envelope.dart';
export 'src/sentry_envelope_item.dart';
export 'src/sentry_options.dart';
export 'src/telemetry/sentry_trace_lifecycle.dart';
// ignore: invalid_export_of_internal_element
export 'src/sentry_trace_origins.dart';
export 'src/span_data_convention.dart';
Expand Down
59 changes: 51 additions & 8 deletions packages/dart/lib/src/hub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import 'client_reports/discard_reason.dart';
import 'profiling.dart';
import 'sentry_tracer.dart';
import 'sentry_traces_sampler.dart';
import 'telemetry/span/sentry_span_sampling_context.dart';
import 'telemetry/span/sentry_span_v2.dart';
import 'transport/data_category.dart';
import 'utils/internal_logger.dart';

/// Configures the scope through the callback.
typedef ScopeCallback = FutureOr<void> Function(Scope);
Expand Down Expand Up @@ -527,8 +529,9 @@ class Hub {
propagationContext.sampleRand ??= Random().nextDouble();

if (samplingDecision == null) {
final samplingContext = SentrySamplingContext(
transactionContext, customSamplingContext ?? {});
final samplingContext = SentrySamplingContext.forTransaction(
transactionContext,
customSamplingContext: customSamplingContext);

samplingDecision = _tracesSampler.sample(
samplingContext,
Expand Down Expand Up @@ -609,13 +612,52 @@ class Hub {
return NoOpSentrySpanV2.instance;
}

final span = RecordingSentrySpanV2(
// Sampling is evaluated once at the root span level.
// All child spans automatically inherit the root span's sampling decision.
//
// Note: Incoming distributed traces (continuing a trace from a remote
// parent via `sentry-trace` header) are not yet supported. This would
// require honoring the incoming `sampled` flag from PropagationContext
// instead of evaluating sampling fresh. This is primarily a backend/server
// use case which the Dart SDK does not currently target.
final RecordingSentrySpanV2 span;
bool isRootSpan = resolvedParentSpan == null;
if (isRootSpan) {
final propagationContext = scope.propagationContext;
final sampleRand =
propagationContext.sampleRand ??= Random().nextDouble();

final samplingContext = SentrySamplingContext.forSpanV2(
SentrySpanSamplingContextV2(name, attributes ?? {}));
final samplingDecision =
_tracesSampler.sample(samplingContext, sampleRand);
propagationContext.applySamplingDecision(samplingDecision.sampled);

if (!samplingDecision.sampled) {
internalLogger.info(
"Span '$name' was not sampled (sample rate: ${samplingDecision.sampleRate}).");
return NoOpSentrySpanV2.instance;
}

span = RecordingSentrySpanV2.root(
traceId: scope.propagationContext.traceId,
name: name,
parentSpan: resolvedParentSpan,
log: options.log,
clock: options.clock,
onSpanEnd: captureSpan);
dscCreator: (span) => SentryTraceContextHeader.fromRecordingSpan(
span, options, scope.replayId),
onSpanEnd: captureSpan,
samplingDecision: samplingDecision,
);
} else {
span = RecordingSentrySpanV2.child(
parent: resolvedParentSpan,
name: name,
clock: options.clock,
dscCreator: (span) => SentryTraceContextHeader.fromRecordingSpan(
span, options, scope.replayId),
onSpanEnd: captureSpan,
);
}

if (attributes != null) {
span.setAttributes(attributes);
Expand Down Expand Up @@ -645,8 +687,9 @@ class Hub {
case NoOpSentrySpanV2():
return;
case RecordingSentrySpanV2 span:
scope.removeActiveSpan(span);
// TODO(next-pr): run this span through span specific pipeline and then forward to span buffer
final item = _peek();
item.scope.removeActiveSpan(span);
return item.client.captureSpan(span, scope: item.scope);
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/dart/lib/src/noop_sentry_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'scope.dart';
import 'sentry_client.dart';
import 'sentry_envelope.dart';
import 'sentry_trace_context_header.dart';
import 'telemetry/span/sentry_span_v2.dart';

class NoOpSentryClient implements SentryClient {
NoOpSentryClient._();
Expand Down Expand Up @@ -69,4 +70,7 @@ class NoOpSentryClient implements SentryClient {

@override
FutureOr<void> captureLog(SentryLog log, {Scope? scope}) async {}

@override
void captureSpan(SentrySpanV2 span, {Scope? scope}) {}
}
20 changes: 20 additions & 0 deletions packages/dart/lib/src/sentry_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'sentry_exception_factory.dart';
import 'sentry_options.dart';
import 'sentry_stack_trace_factory.dart';
import 'sentry_trace_context_header.dart';
import 'telemetry/span/sentry_span_v2.dart';
import 'transport/client_report_transport.dart';
import 'transport/data_category.dart';
import 'transport/http_transport.dart';
Expand Down Expand Up @@ -494,6 +495,25 @@ class SentryClient {
);
}

void captureSpan(
SentrySpanV2 span, {
Scope? scope,
}) {
switch (span) {
case UnsetSentrySpanV2():
_options.log(
SentryLevel.warning,
"captureSpan: span is in an invalid state $UnsetSentrySpanV2.",
);
case NoOpSentrySpanV2():
return;
case RecordingSentrySpanV2 span:
// TODO(next-pr): add common attributes, merge scope attributes

_options.telemetryProcessor.addSpan(span);
}
}

@internal
FutureOr<void> captureLog(
SentryLog log, {
Expand Down
15 changes: 15 additions & 0 deletions packages/dart/lib/src/sentry_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,21 @@ class SentryOptions {
/// sent. Events are picked randomly. Default is null (disabled)
double? sampleRate;

/// Chooses between two tracing systems. You can only use one at a time.
///
/// [SentryTraceLifecycle.streaming] sends each span to Sentry as it finishes.
/// Use [Sentry.startSpan] to create spans. The older transaction APIs
/// ([Sentry.startTransaction], [ISentrySpan.startChild]) will do nothing.
///
/// [SentryTraceLifecycle.static] collects all spans and sends them together
/// when the transaction ends. Use [Sentry.startTransaction] to create traces.
/// The newer span APIs ([Sentry.startSpan]) will do nothing.
///
/// Integrations automatically switch to the correct API based on this setting.
///
/// Defaults to [SentryTraceLifecycle.static].
SentryTraceLifecycle traceLifecycle = SentryTraceLifecycle.static;

/// The ignoreErrors tells the SDK which errors should be not sent to the sentry server.
/// If an null or an empty list is used, the SDK will send all transactions.
/// To use regex add the `^` and the `$` to the string.
Expand Down
82 changes: 77 additions & 5 deletions packages/dart/lib/src/sentry_sampling_context.dart
Original file line number Diff line number Diff line change
@@ -1,21 +1,93 @@
import 'package:meta/meta.dart';

import 'tracing.dart';
import 'sentry_options.dart';
import '../sentry.dart';
import 'telemetry/span/sentry_span_sampling_context.dart';
import 'utils/internal_logger.dart';

/// Context used by [TracesSamplerCallback] to determine if transaction
/// is going to be sampled.
///
/// Note: This class currently supports both static (transaction-based) and
/// streaming (span-based) modes for backwards compatibility. The dual-mode
/// design with placeholder values and runtime checks is a temporary solution.
/// Once the legacy transaction API is removed, this class should be simplified
/// to only support the streaming mode with [SentrySpanSamplingContextV2].
Copy link
Collaborator

Choose a reason for hiding this comment

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

Thx for the explanation why this is here 👍

@immutable
class SentrySamplingContext {
final SentryTransactionContext _transactionContext;
final SentrySpanSamplingContextV2 _spanContext;
final Map<String, dynamic> _customSamplingContext;
final SentryTraceLifecycle _traceLifecycle;

SentrySamplingContext(this._transactionContext, this._customSamplingContext);
// TODO: Remove these placeholders once legacy transaction API is removed.

/// The Transaction context
SentryTransactionContext get transactionContext => _transactionContext;
/// Placeholder for streaming mode where transaction context is not used.
static final _unusedTransactionContext =
SentryTransactionContext('unused', 'unused');

/// Placeholder for static mode where span context is not used.
static final _unusedSpanContext = SentrySpanSamplingContextV2('unused', {});
Comment on lines +24 to +29
Copy link
Contributor Author

Choose a reason for hiding this comment

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

we can't remove/change transactionContext because it's a breaking change so we have to add some mechanism for spanContext to co-exist with it but only have one active at a time

Copy link
Contributor Author

Choose a reason for hiding this comment

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

LMK if there is a better option here

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's fine, it's a straight forward solution as long as we have to support both.


SentrySamplingContext(
this._transactionContext, this._spanContext, this._traceLifecycle,
{Map<String, dynamic>? customSamplingContext})
: _customSamplingContext = customSamplingContext ?? {};

/// Creates a sampling context for SpanV2 (streaming mode).
///
/// In streaming mode, the transaction context is not used - only the
/// span context matters for sampling decisions.
SentrySamplingContext.forSpanV2(SentrySpanSamplingContextV2 spanContext)
: _transactionContext = _unusedTransactionContext,
_spanContext = spanContext,
_traceLifecycle = SentryTraceLifecycle.streaming,
_customSamplingContext = {};

/// Creates a sampling context for legacy transactions (static mode).
///
/// In static mode, the span context is not used - only the transaction
/// context matters for sampling decisions.
SentrySamplingContext.forTransaction(
SentryTransactionContext transactionContext, {
Map<String, dynamic>? customSamplingContext,
}) : _transactionContext = transactionContext,
_spanContext = _unusedSpanContext,
_traceLifecycle = SentryTraceLifecycle.static,
_customSamplingContext = customSamplingContext ?? {};

/// The Transaction context.
///
/// Throws [StateError] if accessed in streaming mode.
///
/// TODO: Remove this getter once legacy transaction API is removed.
/// This runtime check is a temporary solution for backwards compatibility.
SentryTransactionContext get transactionContext {
if (_traceLifecycle != SentryTraceLifecycle.static) {
internalLogger
.error('transactionContext is only available in static mode. '
'Use spanContext for streaming mode.');
}
return _transactionContext;
}

/// The Span V2 sampling context.
///
/// Throws [StateError] if accessed in static mode.
///
/// TODO: Remove the runtime check once legacy transaction API is removed.
/// This runtime check is a temporary solution for backwards compatibility.
SentrySpanSamplingContextV2 get spanContext {
if (_traceLifecycle != SentryTraceLifecycle.streaming) {
internalLogger.error('spanContext is only available in streaming mode. '
'Use transactionContext for static mode.');
}
return _spanContext;
}

/// The given sampling context
Map<String, dynamic> get customSamplingContext =>
Map.unmodifiable(_customSamplingContext);

/// The trace lifecycle mode for this sampling context.
SentryTraceLifecycle get traceLifecycle => _traceLifecycle;
}
31 changes: 28 additions & 3 deletions packages/dart/lib/src/sentry_trace_context_header.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import 'package:meta/meta.dart';

import '../sentry.dart';
import 'protocol/access_aware_map.dart';
import 'protocol/sentry_id.dart';
import 'sentry_baggage.dart';
import 'sentry_options.dart';
import 'telemetry/span/sentry_span_v2.dart';
import 'utils/sample_rate_format.dart';

class SentryTraceContextHeader {
SentryTraceContextHeader(
Expand Down Expand Up @@ -114,4 +114,29 @@ class SentryTraceContextHeader {
replayId: baggage.getReplayId(),
);
}

/// Creates a [SentryTraceContextHeader] from a [RecordingSentrySpanV2].
///
/// Uses the segment span's name as the transaction name.
factory SentryTraceContextHeader.fromRecordingSpan(
RecordingSentrySpanV2 span,
SentryOptions options,
SentryId? replayId,
) {
final sampleRateFormat = SampleRateFormat();
String? formatRate(double? value) =>
value != null ? sampleRateFormat.format(value) : null;

return SentryTraceContextHeader(
span.traceId,
options.parsedDsn.publicKey,
release: options.release,
environment: options.environment,
transaction: span.segmentSpan.name,
sampleRate: formatRate(span.samplingDecision.sampleRate),
sampleRand: span.samplingDecision.sampleRand?.toString(),
sampled: span.samplingDecision.sampled.toString(),
replayId: replayId,
);
}
}
23 changes: 15 additions & 8 deletions packages/dart/lib/src/sentry_traces_sampler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@ class SentryTracesSampler {
SentrySamplingContext samplingContext,
double sampleRand,
) {
final samplingDecision =
samplingContext.transactionContext.samplingDecision;
if (samplingDecision != null) {
return samplingDecision;
final isStaticLifecycle =
samplingContext.traceLifecycle == SentryTraceLifecycle.static;

if (isStaticLifecycle) {
final samplingDecision =
samplingContext.transactionContext.samplingDecision;
if (samplingDecision != null) {
return samplingDecision;
}
}

final tracesSampler = _options.tracesSampler;
Expand All @@ -49,10 +54,12 @@ class SentryTracesSampler {
}
}

final parentSamplingDecision =
samplingContext.transactionContext.parentSamplingDecision;
if (parentSamplingDecision != null) {
return parentSamplingDecision;
if (isStaticLifecycle) {
final parentSamplingDecision =
samplingContext.transactionContext.parentSamplingDecision;
if (parentSamplingDecision != null) {
return parentSamplingDecision;
}
}

double? optionsRate = _options.tracesSampleRate;
Expand Down
15 changes: 15 additions & 0 deletions packages/dart/lib/src/telemetry/sentry_trace_lifecycle.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/// Controls how trace data is collected and transmitted to Sentry.
enum SentryTraceLifecycle {
/// Spans are sent individually as they complete.
///
/// Each span is buffered and transmitted independently without waiting for the entire trace to finish.
streaming,

/// Spans are buffered and sent as a complete transaction.
///
/// All spans in a trace are collected and transmitted together when the
/// root span ends, matching the traditional transaction model.
static,
}

// TODO(next-pr): Guard the APIs that are not supported for the different lifecycle modes.
Loading
Loading