diff --git a/packages/dart/lib/sentry.dart b/packages/dart/lib/sentry.dart index 8d01a3181c..ccfd5b5e8f 100644 --- a/packages/dart/lib/sentry.dart +++ b/packages/dart/lib/sentry.dart @@ -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'; diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index 2568aef275..e2b395092b 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -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 Function(Scope); @@ -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, @@ -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); @@ -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); } } diff --git a/packages/dart/lib/src/noop_sentry_client.dart b/packages/dart/lib/src/noop_sentry_client.dart index 05e526e393..7c43cb9ccd 100644 --- a/packages/dart/lib/src/noop_sentry_client.dart +++ b/packages/dart/lib/src/noop_sentry_client.dart @@ -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._(); @@ -69,4 +70,7 @@ class NoOpSentryClient implements SentryClient { @override FutureOr captureLog(SentryLog log, {Scope? scope}) async {} + + @override + void captureSpan(SentrySpanV2 span, {Scope? scope}) {} } diff --git a/packages/dart/lib/src/sentry_client.dart b/packages/dart/lib/src/sentry_client.dart index 4ed37a2b1f..62828fda31 100644 --- a/packages/dart/lib/src/sentry_client.dart +++ b/packages/dart/lib/src/sentry_client.dart @@ -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'; @@ -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 captureLog( SentryLog log, { diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 4fb772ec21..4dfaed45b5 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -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. diff --git a/packages/dart/lib/src/sentry_sampling_context.dart b/packages/dart/lib/src/sentry_sampling_context.dart index d746e4e38e..c1df137c4a 100644 --- a/packages/dart/lib/src/sentry_sampling_context.dart +++ b/packages/dart/lib/src/sentry_sampling_context.dart @@ -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]. @immutable class SentrySamplingContext { final SentryTransactionContext _transactionContext; + final SentrySpanSamplingContextV2 _spanContext; final Map _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', {}); + + SentrySamplingContext( + this._transactionContext, this._spanContext, this._traceLifecycle, + {Map? 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? 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 get customSamplingContext => Map.unmodifiable(_customSamplingContext); + + /// The trace lifecycle mode for this sampling context. + SentryTraceLifecycle get traceLifecycle => _traceLifecycle; } diff --git a/packages/dart/lib/src/sentry_trace_context_header.dart b/packages/dart/lib/src/sentry_trace_context_header.dart index f94f772dc7..4589cdfc30 100644 --- a/packages/dart/lib/src/sentry_trace_context_header.dart +++ b/packages/dart/lib/src/sentry_trace_context_header.dart @@ -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( @@ -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, + ); + } } diff --git a/packages/dart/lib/src/sentry_traces_sampler.dart b/packages/dart/lib/src/sentry_traces_sampler.dart index 3e1807b015..5b7b530d5b 100644 --- a/packages/dart/lib/src/sentry_traces_sampler.dart +++ b/packages/dart/lib/src/sentry_traces_sampler.dart @@ -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; @@ -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; diff --git a/packages/dart/lib/src/telemetry/sentry_trace_lifecycle.dart b/packages/dart/lib/src/telemetry/sentry_trace_lifecycle.dart new file mode 100644 index 0000000000..9c7addf9b3 --- /dev/null +++ b/packages/dart/lib/src/telemetry/sentry_trace_lifecycle.dart @@ -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. diff --git a/packages/dart/lib/src/telemetry/span/recording_sentry_span_v2.dart b/packages/dart/lib/src/telemetry/span/recording_sentry_span_v2.dart index 4bddfe13ce..540b665d07 100644 --- a/packages/dart/lib/src/telemetry/span/recording_sentry_span_v2.dart +++ b/packages/dart/lib/src/telemetry/span/recording_sentry_span_v2.dart @@ -1,5 +1,9 @@ part of 'sentry_span_v2.dart'; +/// Factory for creating a [SentryTraceContextHeader] from a [RecordingSentrySpanV2]. +typedef DscCreatorCallback = SentryTraceContextHeader Function( + RecordingSentrySpanV2 span); + /// Called when a span ends, allowing the span to be processed or buffered. typedef OnSpanEndCallback = void Function(RecordingSentrySpanV2 span); @@ -7,37 +11,90 @@ typedef OnSpanEndCallback = void Function(RecordingSentrySpanV2 span); /// /// This span captures start/end timestamps, attributes, and status. When /// [end] is called, the span is passed to [OnSpanEndCallback] for processing. +/// +/// Use [RecordingSentrySpanV2.root] to create a root span with a sampling +/// decision, or [RecordingSentrySpanV2.child] to create a child span that +/// inherits sampling from its parent. final class RecordingSentrySpanV2 implements SentrySpanV2 { final SpanId _spanId = SpanId.newId(); final RecordingSentrySpanV2? _parentSpan; final ClockProvider _clock; final OnSpanEndCallback _onSpanEnd; - final SdkLogCallback _log; final DateTime _startTimestamp; final SentryId _traceId; final RecordingSentrySpanV2? _segmentSpan; + final DscCreatorCallback _dscCreator; final Map _attributes = {}; + final SentryTracesSamplingDecision _samplingDecision; // Mutable span state. SentrySpanStatusV2 _status = SentrySpanStatusV2.ok; DateTime? _endTimestamp; String _name; + SentryTraceContextHeader? _frozenDsc; - RecordingSentrySpanV2({ + /// Private constructor. Use [root] or [child] factory constructors. + RecordingSentrySpanV2._({ required SentryId traceId, required String name, required OnSpanEndCallback onSpanEnd, - required SdkLogCallback log, required ClockProvider clock, required RecordingSentrySpanV2? parentSpan, + required DscCreatorCallback dscCreator, + required SentryTracesSamplingDecision samplingDecision, }) : _traceId = parentSpan?.traceId ?? traceId, _name = name, _parentSpan = parentSpan, _clock = clock, _onSpanEnd = onSpanEnd, - _log = log, _startTimestamp = clock(), - _segmentSpan = parentSpan?.segmentSpan ?? parentSpan; + _segmentSpan = parentSpan?.segmentSpan, + _dscCreator = dscCreator, + _samplingDecision = samplingDecision; + + /// Creates a root span with an explicit sampling decision. + /// + /// Root spans are the entry point of a trace segment. + factory RecordingSentrySpanV2.root({ + required SentryId traceId, + required String name, + required OnSpanEndCallback onSpanEnd, + required ClockProvider clock, + required DscCreatorCallback dscCreator, + required SentryTracesSamplingDecision samplingDecision, + }) { + return RecordingSentrySpanV2._( + traceId: traceId, + name: name, + onSpanEnd: onSpanEnd, + clock: clock, + parentSpan: null, + dscCreator: dscCreator, + samplingDecision: samplingDecision, + ); + } + + /// Creates a child span that inherits sampling from its parent. + /// + /// Child spans automatically inherit the sampling decision from the root + /// span of their trace segment. + factory RecordingSentrySpanV2.child({ + required RecordingSentrySpanV2 parent, + required String name, + required OnSpanEndCallback onSpanEnd, + required ClockProvider clock, + required DscCreatorCallback dscCreator, + }) { + return RecordingSentrySpanV2._( + traceId: parent.traceId, + name: name, + onSpanEnd: onSpanEnd, + clock: clock, + parentSpan: parent, + dscCreator: dscCreator, + samplingDecision: parent.samplingDecision, + ); + } @override SentryId get traceId => _traceId; @@ -70,15 +127,33 @@ final class RecordingSentrySpanV2 implements SentrySpanV2 { _endTimestamp = (endTimestamp ?? _clock()).toUtc(); _onSpanEnd(this); - _log(SentryLevel.debug, 'Span ended with endTimestamp: $_endTimestamp'); + internalLogger.debug( + 'Span $name ended with start timestamp: $_startTimestamp, end timestamp: $_endTimestamp'); } + /// The sampling decision for this span's trace. + /// + /// Sampling is evaluated once at the root span level. All child spans + /// automatically inherit the root span's sampling decision. + SentryTracesSamplingDecision get samplingDecision => + segmentSpan._samplingDecision; + /// The local root span of this trace segment. /// /// In distributed tracing, each service (Flutter, backend, etc.) has its own /// segment. Returns `this` if this span is the segment root. RecordingSentrySpanV2 get segmentSpan => _segmentSpan ?? this; + /// Freezes and returns this span's DSC (only meaningful for segment spans). + SentryTraceContextHeader _getOrCreateDsc() => + _frozenDsc ??= _dscCreator(this); + + /// The segment's Dynamic Sampling Context (DSC) header. + /// + /// Created lazily on first access and frozen for the segment's lifetime. + /// All spans in the same segment share this DSC. + SentryTraceContextHeader resolveDsc() => segmentSpan._getOrCreateDsc(); + @override bool get isEnded => _endTimestamp != null; diff --git a/packages/dart/lib/src/telemetry/span/sentry_span_sampling_context.dart b/packages/dart/lib/src/telemetry/span/sentry_span_sampling_context.dart new file mode 100644 index 0000000000..9fafcc16b8 --- /dev/null +++ b/packages/dart/lib/src/telemetry/span/sentry_span_sampling_context.dart @@ -0,0 +1,20 @@ +import 'package:meta/meta.dart'; + +import '../../../sentry.dart'; +import 'sentry_span_v2.dart'; + +@immutable +class SentrySpanSamplingContextV2 { + final String name; + + /// A read-only view of the span’s attributes used by the sampler during the sampling decision. + final Map attributes; + + SentrySpanSamplingContextV2(this.name, this.attributes); + + factory SentrySpanSamplingContextV2.fromSpan(SentrySpanV2 span) => + SentrySpanSamplingContextV2( + span.name, + Map.unmodifiable(span.attributes), + ); +} diff --git a/packages/dart/lib/src/telemetry/span/sentry_span_v2.dart b/packages/dart/lib/src/telemetry/span/sentry_span_v2.dart index 911468158e..58aef93a6c 100644 --- a/packages/dart/lib/src/telemetry/span/sentry_span_v2.dart +++ b/packages/dart/lib/src/telemetry/span/sentry_span_v2.dart @@ -1,6 +1,7 @@ // Span specs: https://develop.sentry.dev/sdk/telemetry/spans/span-api/ import '../../../sentry.dart'; +import '../../utils/internal_logger.dart'; import 'sentry_span_status_v2.dart'; part 'unset_sentry_span_v2.dart'; diff --git a/packages/dart/test/hub_span_test.dart b/packages/dart/test/hub_span_test.dart index aa972eddb5..a8c3d30080 100644 --- a/packages/dart/test/hub_span_test.dart +++ b/packages/dart/test/hub_span_test.dart @@ -242,10 +242,146 @@ void main() { } }); }); + + group('sampling inheritance', () { + test('root span has sampling decision', () { + final hub = fixture.getSut(tracesSampleRate: 1.0); + + final rootSpan = hub.startSpan('root-span'); + + expect(rootSpan, isA()); + final recordingSpan = rootSpan as RecordingSentrySpanV2; + expect(recordingSpan.samplingDecision.sampled, isTrue); + expect(recordingSpan.samplingDecision.sampleRate, equals(1.0)); + }); + + test('child span inherits parent sampling decision', () { + final hub = fixture.getSut(tracesSampleRate: 1.0); + + final rootSpan = hub.startSpan('root-span') as RecordingSentrySpanV2; + final childSpan = + hub.startSpan('child-span') as RecordingSentrySpanV2; + + // Both should have the same sampling decision + expect(childSpan.samplingDecision.sampled, + equals(rootSpan.samplingDecision.sampled)); + expect(childSpan.samplingDecision.sampleRate, + equals(rootSpan.samplingDecision.sampleRate)); + expect(childSpan.samplingDecision.sampleRand, + equals(rootSpan.samplingDecision.sampleRand)); + }); + + test('deeply nested spans all inherit root sampling decision', () { + final hub = fixture.getSut(tracesSampleRate: 1.0); + + final rootSpan = hub.startSpan('root-span') as RecordingSentrySpanV2; + final child1 = hub.startSpan('child-1') as RecordingSentrySpanV2; + final child2 = hub.startSpan('child-2') as RecordingSentrySpanV2; + final child3 = hub.startSpan('child-3') as RecordingSentrySpanV2; + + final rootDecision = rootSpan.samplingDecision; + + // All children should have the same sampling decision + expect(child1.samplingDecision.sampled, equals(rootDecision.sampled)); + expect(child1.samplingDecision.sampleRate, + equals(rootDecision.sampleRate)); + expect(child1.samplingDecision.sampleRand, + equals(rootDecision.sampleRand)); + + expect(child2.samplingDecision.sampled, equals(rootDecision.sampled)); + expect(child2.samplingDecision.sampleRate, + equals(rootDecision.sampleRate)); + expect(child2.samplingDecision.sampleRand, + equals(rootDecision.sampleRand)); + + expect(child3.samplingDecision.sampled, equals(rootDecision.sampled)); + expect(child3.samplingDecision.sampleRate, + equals(rootDecision.sampleRate)); + expect(child3.samplingDecision.sampleRand, + equals(rootDecision.sampleRand)); + }); + + test('sampling is evaluated once at root level', () { + final hub = fixture.getSut( + tracesSampleRate: 1.0, + ); + + // Start root span (should trigger sampling evaluation) + final rootSpan = hub.startSpan('root-span'); + expect(rootSpan, isA()); + + // Start child spans (should NOT trigger new sampling evaluations) + final child1 = hub.startSpan('child-1'); + final child2 = hub.startSpan('child-2'); + + expect(child1, isA()); + expect(child2, isA()); + + // All spans should be recording spans, using the same sampling decision + }); + + test('root span with sampleRate=0 prevents all child spans', () { + final hub = fixture.getSut(tracesSampleRate: 0.0); + + final rootSpan = hub.startSpan('root-span'); + + // Root span should be NoOp when sampled out + expect(rootSpan, isA()); + + // Children should also be NoOp (can't have recording children of NoOp) + final childSpan = hub.startSpan('child-span'); + expect(childSpan, isA()); + }); + + test('sampleRand is reused across all spans in the same trace', () { + final hub = fixture.getSut(tracesSampleRate: 1.0); + + final rootSpan = hub.startSpan('root-span') as RecordingSentrySpanV2; + final sampleRand = rootSpan.samplingDecision.sampleRand; + + // Create multiple child spans + final child1 = hub.startSpan('child-1') as RecordingSentrySpanV2; + final child2 = hub.startSpan('child-2') as RecordingSentrySpanV2; + + // All spans should use the same sampleRand + expect(child1.samplingDecision.sampleRand, equals(sampleRand)); + expect(child2.samplingDecision.sampleRand, equals(sampleRand)); + }); + + test('new trace gets new sampling decision', () { + final hub = fixture.getSut(tracesSampleRate: 1.0); + + // First trace + final rootSpan1 = hub.startSpan('root-1', parentSpan: null) + as RecordingSentrySpanV2; + final decision1 = rootSpan1.samplingDecision; + + // Generate new trace + hub.generateNewTrace(); + + // Second trace + final rootSpan2 = hub.startSpan('root-2', parentSpan: null) + as RecordingSentrySpanV2; + final decision2 = rootSpan2.samplingDecision; + + // New trace should have a different sampleRand + // (extremely unlikely to be the same by chance) + expect(decision2.sampleRand, isNot(equals(decision1.sampleRand))); + }); + }); }); - group('captureSpan', () { - // TODO(next-pr): add test that it was added to buffer + group('when capturing span', () { + test('calls client.captureSpan with span and scope', () { + final hub = fixture.getSut(); + final span = hub.startSpan('test-span'); + + hub.captureSpan(span); + + expect(fixture.client.captureSpanCalls, hasLength(1)); + expect(fixture.client.captureSpanCalls.first.span, equals(span)); + expect(fixture.client.captureSpanCalls.first.scope, isNotNull); + }); test('removes span from active spans on scope', () { final hub = fixture.getSut(); diff --git a/packages/dart/test/mocks/mock_sentry_client.dart b/packages/dart/test/mocks/mock_sentry_client.dart index 00a61cc203..c21379b88b 100644 --- a/packages/dart/test/mocks/mock_sentry_client.dart +++ b/packages/dart/test/mocks/mock_sentry_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; import 'no_such_method_provider.dart'; @@ -11,6 +12,7 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { List captureTransactionCalls = []; List captureFeedbackCalls = []; List captureLogCalls = []; + List captureSpanCalls = []; int closeCalls = 0; @override @@ -90,6 +92,11 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { captureLogCalls.add(CaptureLogCall(log, scope)); } + @override + void captureSpan(SentrySpanV2 span, {Scope? scope}) { + captureSpanCalls.add(CaptureSpanCall(span, scope)); + } + @override void close() { closeCalls = closeCalls + 1; @@ -186,3 +193,10 @@ class CaptureLogCall { CaptureLogCall(this.log, this.scope); } + +class CaptureSpanCall { + final SentrySpanV2 span; + final Scope? scope; + + CaptureSpanCall(this.span, this.scope); +} diff --git a/packages/dart/test/mocks/mock_telemetry_processor.dart b/packages/dart/test/mocks/mock_telemetry_processor.dart new file mode 100644 index 0000000000..db1b80470d --- /dev/null +++ b/packages/dart/test/mocks/mock_telemetry_processor.dart @@ -0,0 +1,25 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/processing/processor.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; + +class MockTelemetryProcessor implements TelemetryProcessor { + final List addedSpans = []; + final List addedLogs = []; + int flushCalls = 0; + int closeCalls = 0; + + @override + void addSpan(RecordingSentrySpanV2 span) { + addedSpans.add(span); + } + + @override + void addLog(SentryLog log) { + addedLogs.add(log); + } + + @override + void flush() { + flushCalls++; + } +} diff --git a/packages/dart/test/scope_test.dart b/packages/dart/test/scope_test.dart index b993a5feb2..2bd400f8c3 100644 --- a/packages/dart/test/scope_test.dart +++ b/packages/dart/test/scope_test.dart @@ -991,13 +991,14 @@ class Fixture { Object? loggedException; RecordingSentrySpanV2 createSpan({String name = 'test-span'}) { - return RecordingSentrySpanV2( + return RecordingSentrySpanV2.root( name: name, traceId: SentryId.newId(), onSpanEnd: (_) {}, - log: options.log, clock: options.clock, - parentSpan: null, + dscCreator: (_) => + SentryTraceContextHeader(SentryId.newId(), 'publicKey'), + samplingDecision: SentryTracesSamplingDecision(true), ); } diff --git a/packages/dart/test/sentry_client_test.dart b/packages/dart/test/sentry_client_test.dart index 534f665774..7944dda0fa 100644 --- a/packages/dart/test/sentry_client_test.dart +++ b/packages/dart/test/sentry_client_test.dart @@ -16,6 +16,7 @@ import 'package:sentry/src/transport/data_category.dart'; import 'package:sentry/src/transport/noop_transport.dart'; import 'package:sentry/src/transport/spotlight_http_transport.dart'; import 'package:sentry/src/utils/iterable_utils.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; import 'package:test/test.dart'; import 'package:sentry/src/noop_log_batcher.dart'; import 'package:sentry/src/sentry_log_batcher.dart'; @@ -25,6 +26,7 @@ import 'package:http/http.dart' as http; import 'mocks.dart'; import 'mocks/mock_client_report_recorder.dart'; import 'mocks/mock_hub.dart'; +import 'mocks/mock_telemetry_processor.dart'; import 'mocks/mock_transport.dart'; import 'test_utils.dart'; import 'utils/url_details_test.dart'; @@ -2071,6 +2073,53 @@ void main() { }); }); + group('SentryClient', () { + group('when capturing span', () { + late Fixture fixture; + late MockTelemetryProcessor processor; + + setUp(() { + fixture = Fixture(); + processor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = processor; + }); + + test('adds recording span to telemetry processor', () { + final client = fixture.getSut(); + + final span = RecordingSentrySpanV2.root( + name: 'test-span', + traceId: SentryId.newId(), + onSpanEnd: (_) {}, + clock: fixture.options.clock, + dscCreator: (s) => SentryTraceContextHeader(SentryId.newId(), 'key'), + samplingDecision: SentryTracesSamplingDecision(true), + ); + + client.captureSpan(span); + + expect(processor.addedSpans, hasLength(1)); + expect(processor.addedSpans.first, equals(span)); + }); + + test('does nothing for NoOpSentrySpanV2', () { + final client = fixture.getSut(); + + client.captureSpan(const NoOpSentrySpanV2()); + + expect(processor.addedSpans, isEmpty); + }); + + test('does nothing for UnsetSentrySpanV2', () { + final client = fixture.getSut(); + + client.captureSpan(const UnsetSentrySpanV2()); + + expect(processor.addedSpans, isEmpty); + }); + }); + }); + group('SentryClient captures envelope', () { late Fixture fixture; final fakeEnvelope = getFakeEnvelope(); diff --git a/packages/dart/test/sentry_sampling_context_test.dart b/packages/dart/test/sentry_sampling_context_test.dart new file mode 100644 index 0000000000..6ff12788ca --- /dev/null +++ b/packages/dart/test/sentry_sampling_context_test.dart @@ -0,0 +1,95 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_sampling_context.dart'; +import 'package:test/test.dart'; + +void main() { + group('SentrySamplingContext', () { + group('when created for SpanV2', () { + test('sets streaming lifecycle', () { + final spanContext = SentrySpanSamplingContextV2( + 'span-name', {'key': SentryAttribute.string('value')}); + + final context = SentrySamplingContext.forSpanV2(spanContext); + + expect(context.traceLifecycle, equals(SentryTraceLifecycle.streaming)); + }); + + test('provides access to span context', () { + final spanContext = SentrySpanSamplingContextV2('my-span', {}); + + final context = SentrySamplingContext.forSpanV2(spanContext); + + expect(context.spanContext.name, equals('my-span')); + }); + + test('returns empty customSamplingContext by default', () { + final spanContext = SentrySpanSamplingContextV2('span', {}); + + final context = SentrySamplingContext.forSpanV2(spanContext); + + expect(context.customSamplingContext, isEmpty); + }); + }); + + group('when created for transaction', () { + test('sets static lifecycle', () { + final transactionContext = SentryTransactionContext('tx-name', 'op'); + + final context = + SentrySamplingContext.forTransaction(transactionContext); + + expect(context.traceLifecycle, equals(SentryTraceLifecycle.static)); + }); + + test('provides access to transaction context', () { + final transactionContext = + SentryTransactionContext('my-transaction', 'http'); + + final context = + SentrySamplingContext.forTransaction(transactionContext); + + expect(context.transactionContext.name, equals('my-transaction')); + expect(context.transactionContext.operation, equals('http')); + }); + + test('preserves customSamplingContext', () { + final transactionContext = SentryTransactionContext('tx', 'op'); + final customContext = {'userId': '123', 'premium': true}; + + final context = SentrySamplingContext.forTransaction( + transactionContext, + customSamplingContext: customContext, + ); + + expect(context.customSamplingContext, equals(customContext)); + }); + + test('returns unmodifiable customSamplingContext', () { + final transactionContext = SentryTransactionContext('tx', 'op'); + final customContext = {'key': 'value'}; + + final context = SentrySamplingContext.forTransaction( + transactionContext, + customSamplingContext: customContext, + ); + + expect(() => context.customSamplingContext['newKey'] = 'newValue', + throwsA(isA())); + }); + }); + }); + + group('SentrySpanSamplingContextV2', () { + test('stores name and attributes', () { + final attributes = { + 'key1': SentryAttribute.string('value1'), + 'key2': SentryAttribute.int(42), + }; + + final context = SentrySpanSamplingContextV2('my-span', attributes); + + expect(context.name, equals('my-span')); + expect(context.attributes, equals(attributes)); + }); + }); +} diff --git a/packages/dart/test/sentry_trace_context_header_test.dart b/packages/dart/test/sentry_trace_context_header_test.dart index eb97f87913..4080ba1d1a 100644 --- a/packages/dart/test/sentry_trace_context_header_test.dart +++ b/packages/dart/test/sentry_trace_context_header_test.dart @@ -1,8 +1,10 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; import 'package:test/test.dart'; import 'mocks.dart'; +import 'test_utils.dart'; void main() { group('$SentryTraceContextHeader', () { @@ -68,5 +70,176 @@ void main() { 'sentry-replay_id=456', ); }); + + group('when creating from RecordingSentrySpanV2', () { + late _Fixture fixture; + + setUp(() { + fixture = _Fixture(); + }); + + test('uses traceId from span', () { + final spanTraceId = SentryId.newId(); + final span = fixture.createSpan(traceId: spanTraceId); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + expect(dsc.traceId, equals(spanTraceId)); + }); + + test('uses publicKey from hub options', () { + final span = fixture.createSpan(); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + expect(dsc.publicKey, equals('public')); + }); + + test('uses release from hub options', () { + fixture.options.release = 'test-release@1.0.0'; + final span = fixture.createSpan(); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + expect(dsc.release, equals('test-release@1.0.0')); + }); + + test('uses environment from hub options', () { + fixture.options.environment = 'test-environment'; + final span = fixture.createSpan(); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + expect(dsc.environment, equals('test-environment')); + }); + + test('uses segment span name as transaction', () { + final span = fixture.createSpan(name: 'my-transaction-name'); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + expect(dsc.transaction, equals('my-transaction-name')); + }); + + test('uses sampleRate from span sampling decision', () { + // Set options tracesSampleRate to a DIFFERENT value than the span's + // sampling decision to verify the DSC uses the span's rate + fixture.options.tracesSampleRate = 0.1; + final span = fixture.createSpan( + samplingDecision: SentryTracesSamplingDecision( + true, + sampleRate: 0.75, + ), + ); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + // Should use span's sampling decision rate (0.75), not options rate (0.1) + expect(dsc.sampleRate, equals('0.75')); + }); + + test('uses sampleRand from span sampling decision', () { + fixture.hub.scope.propagationContext.sampleRand = 0.123456; + final span = fixture.createSpan( + samplingDecision: SentryTracesSamplingDecision( + true, + sampleRand: 0.123456, + ), + ); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + expect(dsc.sampleRand, equals('0.123456')); + }); + + test('uses sampled from propagation context', () { + fixture.hub.scope.propagationContext.applySamplingDecision(true); + final span = fixture.createSpan(); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + expect(dsc.sampled, equals('true')); + }); + + test('uses replayId from scope', () { + final replayId = SentryId.newId(); + fixture.hub.scope.replayId = replayId; + final span = fixture.createSpan(); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + expect(dsc.replayId, equals(replayId)); + }); + + test('with null replayId returns null', () { + fixture.hub.scope.replayId = null; + final span = fixture.createSpan(); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + span, fixture.options, fixture.hub.scope.replayId); + + expect(dsc.replayId, isNull); + }); + + test('with child span uses segment span name as transaction', () { + final rootSpan = fixture.createSpan(name: 'root-transaction'); + final childSpan = + fixture.createChildSpan(parent: rootSpan, name: 'child-operation'); + + final dsc = SentryTraceContextHeader.fromRecordingSpan( + childSpan, fixture.options, fixture.hub.scope.replayId); + + // Should use the root/segment span name, not the child span name + expect(dsc.transaction, equals('root-transaction')); + }); + }); }); } + +class _Fixture { + final options = defaultTestOptions(); + late final Hub hub; + + _Fixture() { + hub = Hub(options); + } + + RecordingSentrySpanV2 createSpan({ + String name = 'test-span', + SentryId? traceId, + SentryTracesSamplingDecision? samplingDecision, + }) { + return RecordingSentrySpanV2.root( + name: name, + traceId: traceId ?? SentryId.newId(), + onSpanEnd: (_) {}, + clock: options.clock, + dscCreator: (span) => SentryTraceContextHeader.fromRecordingSpan( + span, options, hub.scope.replayId), + samplingDecision: samplingDecision ?? SentryTracesSamplingDecision(true), + ); + } + + RecordingSentrySpanV2 createChildSpan({ + required RecordingSentrySpanV2 parent, + String name = 'child-span', + }) { + return RecordingSentrySpanV2.child( + parent: parent, + name: name, + onSpanEnd: (_) {}, + clock: options.clock, + dscCreator: (span) => SentryTraceContextHeader.fromRecordingSpan( + span, options, hub.scope.replayId), + ); + } +} diff --git a/packages/dart/test/sentry_traces_sampler_test.dart b/packages/dart/test/sentry_traces_sampler_test.dart index cb2d96ecf1..6ae1279ab2 100644 --- a/packages/dart/test/sentry_traces_sampler_test.dart +++ b/packages/dart/test/sentry_traces_sampler_test.dart @@ -1,5 +1,6 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_traces_sampler.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_sampling_context.dart'; import 'package:test/test.dart'; import 'test_utils.dart'; @@ -12,110 +13,178 @@ void main() { fixture = Fixture(); }); - test('transactionContext has sampled', () { - final sut = fixture.getSut(); + group('SentryTracesSampler', () { + group('when sampling SpanV2', () { + test('samples with tracesSampleRate 1.0', () { + final sut = fixture.getSut(tracesSampleRate: 1.0); + final spanContext = SentrySpanSamplingContextV2('span-name', {}); + final context = SentrySamplingContext.forSpanV2(spanContext); + + final decision = sut.sample(context, _sampleRand); + + expect(decision.sampled, isTrue); + expect(decision.sampleRate, equals(1.0)); + }); + + test('does not sample with tracesSampleRate 0.0', () { + final sut = fixture.getSut(tracesSampleRate: 0.0); + final spanContext = SentrySpanSamplingContextV2('span-name', {}); + final context = SentrySamplingContext.forSpanV2(spanContext); + + final decision = sut.sample(context, _sampleRand); + + expect(decision.sampled, isFalse); + expect(decision.sampleRate, equals(0.0)); + }); + + test('uses tracesSampler callback when provided', () { + double? sampler(SentrySamplingContext samplingContext) { + expect(samplingContext.traceLifecycle, + equals(SentryTraceLifecycle.streaming)); + return 1.0; + } + + final sut = + fixture.getSut(tracesSampleRate: null, tracesSampler: sampler); + final spanContext = SentrySpanSamplingContextV2('test-span', {}); + final context = SentrySamplingContext.forSpanV2(spanContext); + + final decision = sut.sample(context, _sampleRand); - final trContext = SentryTransactionContext( - 'name', - 'op', - samplingDecision: SentryTracesSamplingDecision(true), - ); - final context = SentrySamplingContext(trContext, {}); - - expect(sut.sample(context, _sampleRand).sampled, true); - }); - - test('options has sampler', () { - double? sampler(SentrySamplingContext samplingContext) { - return 1.0; - } - - final sut = fixture.getSut( - tracesSampleRate: null, - tracesSampler: sampler, - ); - - final trContext = SentryTransactionContext( - 'name', - 'op', - ); - final context = SentrySamplingContext(trContext, {}); - - expect(sut.sample(context, _sampleRand).sampled, true); - }); - - test('transactionContext has parentSampled', () { - final sut = fixture.getSut(tracesSampleRate: null); - - final trContext = SentryTransactionContext( - 'name', - 'op', - parentSamplingDecision: SentryTracesSamplingDecision(true), - ); - final context = SentrySamplingContext(trContext, {}); - - expect(sut.sample(context, _sampleRand).sampled, true); - }); - - test('options has rate 1.0', () { - final sut = fixture.getSut(); - - final trContext = SentryTransactionContext( - 'name', - 'op', - ); - final context = SentrySamplingContext(trContext, {}); - - expect(sut.sample(context, _sampleRand).sampled, true); - }); - - test('options has rate 0.0', () { - final sut = fixture.getSut(tracesSampleRate: 0.0); - - final trContext = SentryTransactionContext( - 'name', - 'op', - ); - final context = SentrySamplingContext(trContext, {}); - - expect(sut.sample(context, _sampleRand).sampled, false); - }); - - test('does not sample if tracesSampleRate and tracesSampleRate are null', () { - final sut = fixture.getSut(tracesSampleRate: null, tracesSampler: null); - - final trContext = SentryTransactionContext( - 'name', - 'op', - ); - final context = SentrySamplingContext(trContext, {}); - final samplingDecision = sut.sample(context, _sampleRand); - - expect(samplingDecision.sampleRate, isNull); - expect(samplingDecision.sampleRand, isNull); - expect(samplingDecision.sampled, false); - }); - - test('tracesSampler exception is handled', () { - fixture.options.automatedTestMode = false; - final sut = fixture.getSut(debug: true); - - final exception = Exception("tracesSampler exception"); - double? sampler(SentrySamplingContext samplingContext) { - throw exception; - } - - fixture.options.tracesSampler = sampler; - - final trContext = SentryTransactionContext( - 'name', - 'op', - ); - final context = SentrySamplingContext(trContext, {}); - sut.sample(context, _sampleRand); - - expect(fixture.loggedException, exception); - expect(fixture.loggedLevel, SentryLevel.error); + expect(decision.sampled, isTrue); + }); + + test('does not sample when tracing is disabled', () { + final sut = fixture.getSut(tracesSampleRate: null, tracesSampler: null); + final spanContext = SentrySpanSamplingContextV2('span-name', {}); + final context = SentrySamplingContext.forSpanV2(spanContext); + + final decision = sut.sample(context, _sampleRand); + + expect(decision.sampled, isFalse); + expect(decision.sampleRate, isNull); + expect(decision.sampleRand, isNull); + }); + + test('preserves sampleRand in decision', () { + final sut = fixture.getSut(tracesSampleRate: 1.0); + final spanContext = SentrySpanSamplingContextV2('span-name', {}); + final context = SentrySamplingContext.forSpanV2(spanContext); + const expectedSampleRand = 0.42; + + final decision = sut.sample(context, expectedSampleRand); + + expect(decision.sampleRand, equals(expectedSampleRand)); + }); + }); + + group('when sampling transaction', () { + test('samples when transactionContext has sampled', () { + final sut = fixture.getSut(); + + final trContext = SentryTransactionContext( + 'name', + 'op', + samplingDecision: SentryTracesSamplingDecision(true), + ); + final context = SentrySamplingContext.forTransaction(trContext); + + expect(sut.sample(context, _sampleRand).sampled, true); + }); + + test('uses tracesSampler callback when provided', () { + double? sampler(SentrySamplingContext samplingContext) { + return 1.0; + } + + final sut = fixture.getSut( + tracesSampleRate: null, + tracesSampler: sampler, + ); + + final trContext = SentryTransactionContext( + 'name', + 'op', + ); + final context = SentrySamplingContext.forTransaction(trContext); + + expect(sut.sample(context, _sampleRand).sampled, true); + }); + + test('samples when transactionContext has parentSampled', () { + final sut = fixture.getSut(tracesSampleRate: null); + + final trContext = SentryTransactionContext( + 'name', + 'op', + parentSamplingDecision: SentryTracesSamplingDecision(true), + ); + final context = SentrySamplingContext.forTransaction(trContext); + + expect(sut.sample(context, _sampleRand).sampled, true); + }); + + test('samples with tracesSampleRate 1.0', () { + final sut = fixture.getSut(); + + final trContext = SentryTransactionContext( + 'name', + 'op', + ); + final context = SentrySamplingContext.forTransaction(trContext); + + expect(sut.sample(context, _sampleRand).sampled, true); + }); + + test('does not sample with tracesSampleRate 0.0', () { + final sut = fixture.getSut(tracesSampleRate: 0.0); + + final trContext = SentryTransactionContext( + 'name', + 'op', + ); + final context = SentrySamplingContext.forTransaction(trContext); + + expect(sut.sample(context, _sampleRand).sampled, false); + }); + + test('does not sample when tracing is disabled', () { + final sut = fixture.getSut(tracesSampleRate: null, tracesSampler: null); + + final trContext = SentryTransactionContext( + 'name', + 'op', + ); + final context = SentrySamplingContext.forTransaction(trContext); + final samplingDecision = sut.sample(context, _sampleRand); + + expect(samplingDecision.sampleRate, isNull); + expect(samplingDecision.sampleRand, isNull); + expect(samplingDecision.sampled, false); + }); + + test('handles tracesSampler exception gracefully', () { + fixture.options.automatedTestMode = false; + final sut = fixture.getSut(debug: true); + + final exception = Exception("tracesSampler exception"); + double? sampler(SentrySamplingContext samplingContext) { + throw exception; + } + + fixture.options.tracesSampler = sampler; + + final trContext = SentryTransactionContext( + 'name', + 'op', + ); + final context = SentrySamplingContext.forTransaction(trContext); + sut.sample(context, _sampleRand); + + expect(fixture.loggedException, exception); + expect(fixture.loggedLevel, SentryLevel.error); + }); + }); }); } diff --git a/packages/dart/test/telemetry/processing/telemetry_processor_integration_test.dart b/packages/dart/test/telemetry/processing/telemetry_processor_integration_test.dart index fec6c9b47e..2fedd77d1e 100644 --- a/packages/dart/test/telemetry/processing/telemetry_processor_integration_test.dart +++ b/packages/dart/test/telemetry/processing/telemetry_processor_integration_test.dart @@ -148,13 +148,14 @@ class _Fixture { } RecordingSentrySpanV2 createSpan() { - return RecordingSentrySpanV2( + return RecordingSentrySpanV2.root( name: 'test-span', traceId: SentryId.newId(), onSpanEnd: (_) {}, - log: options.log, clock: options.clock, - parentSpan: null, + dscCreator: (_) => + SentryTraceContextHeader(SentryId.newId(), 'publicKey'), + samplingDecision: SentryTracesSamplingDecision(true), ); } } diff --git a/packages/dart/test/telemetry/processing/telemetry_processor_test.dart b/packages/dart/test/telemetry/processing/telemetry_processor_test.dart index fb88f3da29..6e20939b09 100644 --- a/packages/dart/test/telemetry/processing/telemetry_processor_test.dart +++ b/packages/dart/test/telemetry/processing/telemetry_processor_test.dart @@ -137,13 +137,14 @@ class Fixture { } RecordingSentrySpanV2 createSpan({String name = 'test-span'}) { - return RecordingSentrySpanV2( + return RecordingSentrySpanV2.root( name: name, traceId: SentryId.newId(), onSpanEnd: (_) {}, - log: options.log, clock: options.clock, - parentSpan: null, + dscCreator: (_) => + SentryTraceContextHeader(SentryId.newId(), 'publicKey'), + samplingDecision: SentryTracesSamplingDecision(true), ); } @@ -151,13 +152,13 @@ class Fixture { required RecordingSentrySpanV2 parent, String name = 'child-span', }) { - return RecordingSentrySpanV2( + return RecordingSentrySpanV2.child( + parent: parent, name: name, - traceId: SentryId.newId(), onSpanEnd: (_) {}, - log: options.log, clock: options.clock, - parentSpan: parent, + dscCreator: (_) => + SentryTraceContextHeader(SentryId.newId(), 'publicKey'), ); } diff --git a/packages/dart/test/telemetry/span/span_test.dart b/packages/dart/test/telemetry/span/span_test.dart index 3f71323599..3bd8a372c3 100644 --- a/packages/dart/test/telemetry/span/span_test.dart +++ b/packages/dart/test/telemetry/span/span_test.dart @@ -209,6 +209,127 @@ void main() { }); }); + group('when resolving DSC', () { + test('creates DSC on first access for root span', () { + final span = fixture.createSpan(name: 'root-span'); + + final dsc = span.resolveDsc(); + + expect(dsc, isNotNull); + expect(dsc.publicKey, equals('publicKey')); + }); + + test('returns same DSC on subsequent access', () { + final span = fixture.createSpan(name: 'root-span'); + + final dsc1 = span.resolveDsc(); + final dsc2 = span.resolveDsc(); + + expect(identical(dsc1, dsc2), isTrue); + }); + + test('returns DSC from segment span for child span', () { + final root = fixture.createSpan(name: 'root'); + final child = fixture.createSpan(name: 'child', parentSpan: root); + + final rootDsc = root.resolveDsc(); + final childDsc = child.resolveDsc(); + + expect(identical(rootDsc, childDsc), isTrue); + }); + + test('returns same DSC for deeply nested spans', () { + final root = fixture.createSpan(name: 'root'); + final child = fixture.createSpan(name: 'child', parentSpan: root); + final grandchild = + fixture.createSpan(name: 'grandchild', parentSpan: child); + + final rootDsc = root.resolveDsc(); + final childDsc = child.resolveDsc(); + final grandchildDsc = grandchild.resolveDsc(); + + expect(identical(rootDsc, childDsc), isTrue); + expect(identical(rootDsc, grandchildDsc), isTrue); + }); + + test('freezes DSC after first access', () { + var callCount = 0; + final span = fixture.createSpan( + name: 'root-span', + dscCreator: (s) { + callCount++; + return SentryTraceContextHeader(SentryId.newId(), 'publicKey'); + }, + ); + + span.resolveDsc(); + span.resolveDsc(); + span.resolveDsc(); + + expect(callCount, equals(1), + reason: 'DSC creator should only be called once'); + }); + }); + + group('when accessing samplingDecision', () { + test('returns stored decision for root span', () { + final decision = SentryTracesSamplingDecision( + true, + sampleRate: 0.5, + sampleRand: 0.25, + ); + final span = fixture.createSpan( + name: 'root-span', + samplingDecision: decision, + ); + + expect(span.samplingDecision.sampled, isTrue); + expect(span.samplingDecision.sampleRate, equals(0.5)); + expect(span.samplingDecision.sampleRand, equals(0.25)); + }); + + test('returns inherited decision for child span', () { + final decision = SentryTracesSamplingDecision( + true, + sampleRate: 0.75, + sampleRand: 0.1, + ); + final root = fixture.createSpan( + name: 'root', + samplingDecision: decision, + ); + final child = fixture.createSpan(name: 'child', parentSpan: root); + + expect(child.samplingDecision.sampled, + equals(root.samplingDecision.sampled)); + expect(child.samplingDecision.sampleRate, + equals(root.samplingDecision.sampleRate)); + expect(child.samplingDecision.sampleRand, + equals(root.samplingDecision.sampleRand)); + }); + + test('returns root decision for deeply nested span', () { + final decision = SentryTracesSamplingDecision( + true, + sampleRate: 0.3, + sampleRand: 0.99, + ); + final root = fixture.createSpan( + name: 'root', + samplingDecision: decision, + ); + final child = fixture.createSpan(name: 'child', parentSpan: root); + final grandchild = + fixture.createSpan(name: 'grandchild', parentSpan: child); + + expect(grandchild.samplingDecision.sampled, equals(decision.sampled)); + expect(grandchild.samplingDecision.sampleRate, + equals(decision.sampleRate)); + expect(grandchild.samplingDecision.sampleRand, + equals(decision.sampleRand)); + }); + }); + group('toJson', () { test('serializes basic span without parent', () { final span = fixture.createSpan(name: 'test-span'); @@ -364,14 +485,28 @@ class Fixture { RecordingSentrySpanV2? parentSpan, SentryId? traceId, OnSpanEndCallback? onSpanEnded, + DscCreatorCallback? dscCreator, + SentryTracesSamplingDecision? samplingDecision, }) { - return RecordingSentrySpanV2( + final defaultDscCreator = (RecordingSentrySpanV2 span) => + SentryTraceContextHeader(SentryId.newId(), 'publicKey'); + + if (parentSpan != null) { + return RecordingSentrySpanV2.child( + parent: parentSpan, + name: name, + onSpanEnd: onSpanEnded ?? (_) {}, + clock: options.clock, + dscCreator: dscCreator ?? defaultDscCreator, + ); + } + return RecordingSentrySpanV2.root( name: name, traceId: traceId ?? SentryId.newId(), onSpanEnd: onSpanEnded ?? (_) {}, - log: options.log, clock: options.clock, - parentSpan: parentSpan, + dscCreator: dscCreator ?? defaultDscCreator, + samplingDecision: samplingDecision ?? SentryTracesSamplingDecision(true), ); } } diff --git a/packages/flutter/example/lib/main.dart b/packages/flutter/example/lib/main.dart index d1e2a18fef..94ba188fb0 100644 --- a/packages/flutter/example/lib/main.dart +++ b/packages/flutter/example/lib/main.dart @@ -88,7 +88,6 @@ Future setupSentry( options.debug = kDebugMode; options.spotlight = Spotlight(enabled: true); options.enableTimeToFullDisplayTracing = true; - options.maxRequestBodySize = MaxRequestBodySize.always; options.navigatorKey = navigatorKey;