Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

- Ensure consistent sampling per trace ([#3079](https://github.com/getsentry/sentry-dart/pull/3079))

### Enhancements

- Add sampled flag in propagation context ([#3084](https://github.com/getsentry/sentry-dart/pull/3084))

### Dependencies

- Bump Native SDK from v0.9.0 to v0.9.1 ([#3018](https://github.com/getsentry/sentry-dart/pull/3018))
Expand Down
11 changes: 8 additions & 3 deletions dart/lib/src/hub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,11 @@ class Hub {
transactionContext.origin ??= SentryTraceOrigins.manual;
transactionContext.traceId = propagationContext.traceId;

// Persist the "sampled" decision onto the propagation context the
// first time we obtain one for the current trace.
// Subsequent transactions do not affect the sampled flag.
propagationContext.applySamplingDecision(samplingDecision.sampled);

SentryProfiler? profiler;
if (_profilerFactory != null &&
_tracesSampler.sampleProfiling(samplingDecision)) {
Expand Down Expand Up @@ -543,9 +548,9 @@ class Hub {

@internal
void generateNewTrace() {
scope.propagationContext.traceId = SentryId.newId();
// Reset sampleRand so that a new one is generated for the new trace when a new transaction is started
scope.propagationContext.sampleRand = null;
// Create a brand-new trace and reset the sampling flag and sampleRand so
// that the next root transaction can set it again.
scope.propagationContext.resetTrace();
}

/// Gets the current active transaction or span.
Expand Down
34 changes: 32 additions & 2 deletions dart/lib/src/propagation_context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,48 @@ class PropagationContext {
/// The dynamic sampling context.
SentryBaggage? baggage;

bool? _sampled;

/// Indicates whether the current trace is sampled or not.
///
/// This flag follows the lifecycle of a trace:
/// * It starts as `null` (undecided).
/// * The **first** transaction that receives a sampling decision (root
/// transaction) sets the flag to the decided value. Subsequent
/// transactions for the same trace MUST NOT change the value.
/// * When a new trace is started (i.e. when a new `traceId` is generated),
/// the flag is reset back to `null`.
///
/// The flag is propagated via the `sentry-trace` header so that downstream
/// services can honour the original sampling decision.
bool? get sampled => _sampled;

/// Applies the sampling decision exactly once per trace.
void applySamplingDecision(bool sampled) {
_sampled ??= sampled;
}

/// Random number generated for sampling decisions.
///
/// This value must be generated **once per trace** and reused across all
/// child spans and transactions that belong to the same trace. It is reset
/// whenever a new trace is started.
double? sampleRand;

/// Starts a brand-new trace (new ID, new sampling value & sampled state).
void resetTrace() {
traceId = SentryId.newId();
sampleRand = null;
_sampled = null;
}

/// Baggage header to attach to http headers.
SentryBaggageHeader? toBaggageHeader() =>
baggage != null ? SentryBaggageHeader.fromBaggage(baggage!) : null;

/// Sentry trace header to attach to http headers.
SentryTraceHeader toSentryTrace() =>
generateSentryTraceHeader(traceId: traceId);
SentryTraceHeader toSentryTrace() => generateSentryTraceHeader(
traceId: traceId,
sampled: sampled,
);
}
10 changes: 6 additions & 4 deletions dart/lib/src/protocol/sentry_trace_context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,11 @@ class SentryTraceContext {
factory SentryTraceContext.fromPropagationContext(
PropagationContext propagationContext) {
return SentryTraceContext(
traceId: propagationContext.traceId,
spanId: SpanId.newId(),
operation: 'default',
replayId: propagationContext.baggage?.getReplayId());
traceId: propagationContext.traceId,
spanId: SpanId.newId(),
operation: 'default',
sampled: propagationContext.sampled,
replayId: propagationContext.baggage?.getReplayId(),
);
}
}
157 changes: 157 additions & 0 deletions dart/test/propagation_context_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import 'package:sentry/sentry.dart';
import 'package:sentry/src/sentry_tracer.dart';
import 'package:test/test.dart';

import 'test_utils.dart';

void main() {
group('PropagationContext', () {
group('traceId', () {
test('is a new trace id by default', () {
final hub = Hub(defaultTestOptions());
final sut = hub.scope.propagationContext;
final traceId = sut.traceId;
expect(traceId, isNotNull);
});

test('is reused for transactions within the same trace', () {
final options = defaultTestOptions()..tracesSampleRate = 1.0;
final hub = Hub(options);
final sut = hub.scope.propagationContext;

final tx1 = hub.startTransaction('tx1', 'op') as SentryTracer;
final traceId1 = sut.traceId;

final tx2 = hub.startTransaction('tx2', 'op') as SentryTracer;
final traceId2 = sut.traceId;

expect(tx1.context.traceId, equals(tx2.context.traceId));
expect(tx1.context.traceId, equals(traceId1));
expect(traceId1, equals(traceId2));
});
});

group('sampleRand', () {
test('is null by default', () {
final hub = Hub(defaultTestOptions());
final sut = hub.scope.propagationContext;
final sampleRand = sut.sampleRand;
expect(sampleRand, isNull);
});

test('is set by the first transaction and stays unchanged', () {
final options = defaultTestOptions()..tracesSampleRate = 1.0;
final hub = Hub(options);
final sut = hub.scope.propagationContext;

final tx1 = hub.startTransaction('tx1', 'op') as SentryTracer;
final rand1 = tx1.samplingDecision?.sampleRand;
expect(rand1, isNotNull);

final tx2 = hub.startTransaction('tx2', 'op') as SentryTracer;
final rand2 = tx2.samplingDecision?.sampleRand;

expect(rand2, equals(rand1));
expect(rand1, equals(sut.sampleRand));
});
});

group('sampled', () {
test('is null by default', () {
final hub = Hub(defaultTestOptions()..tracesSampleRate = 1.0);
final sut = hub.scope.propagationContext;
expect(sut.sampled, isNull);
});

test('is set by the first transaction and stays unchanged', () {
final hub = Hub(defaultTestOptions()..tracesSampleRate = 1.0);
final sut = hub.scope.propagationContext;
// 1. Start the first (root) transaction with an explicit sampled = true.
final txContextTrue = SentryTransactionContext(
'trx',
'op',
samplingDecision: SentryTracesSamplingDecision(true),
);
hub.startTransactionWithContext(txContextTrue);

expect(sut.sampled, isTrue);

// 2. Start a second transaction with sampled = false – the flag must not change.
final txContextFalse = SentryTransactionContext(
'trx-2',
'op',
samplingDecision: SentryTracesSamplingDecision(false),
);
hub.startTransactionWithContext(txContextFalse);

expect(sut.sampled, isTrue,
reason: 'sampled flag must remain unchanged for the trace');
});

test('is reset when a new trace is generated', () {
final hub = Hub(defaultTestOptions()..tracesSampleRate = 1.0);
final sut = hub.scope.propagationContext;
final txContext = SentryTransactionContext(
'trx',
'op',
samplingDecision: SentryTracesSamplingDecision(true),
);
hub.startTransactionWithContext(txContext);
expect(sut.sampled, isTrue);

// Simulate new trace.
hub.generateNewTrace();
expect(sut.sampled, isNull);
});

test('applySamplingDecision only sets sampled flag once', () {
final hub = Hub(defaultTestOptions()..tracesSampleRate = 1.0);
final sut = hub.scope.propagationContext;

expect(sut.sampled, isNull);
sut.applySamplingDecision(true);
expect(sut.sampled, isTrue);
sut.applySamplingDecision(false);
expect(sut.sampled, isTrue);
});
});

group('resetTrace', () {
test('resets values', () {
final hub = Hub(defaultTestOptions()..tracesSampleRate = 1.0);
final sut = hub.scope.propagationContext;

final traceId = SentryId.newId();
sut.traceId = traceId;
sut.sampleRand = 1.0;
sut.applySamplingDecision(true);

sut.resetTrace();

expect(sut.traceId, isNot(traceId));
expect(sut.sampleRand, isNull);
expect(sut.sampled, isNull);
});
});

group('toSentryTrace', () {
test('header reflects values', () {
final options = defaultTestOptions()..tracesSampleRate = 1.0;
final hub = Hub(options);
final sut = hub.scope.propagationContext;

final txContext = SentryTransactionContext(
'trx',
'op',
samplingDecision: SentryTracesSamplingDecision(true),
);
hub.startTransactionWithContext(txContext);

final header = sut.toSentryTrace();
expect(header.sampled, isTrue);
expect(header.value.split('-').length, 3,
reason: 'header must contain the sampled decision');
});
});
});
}
43 changes: 0 additions & 43 deletions dart/test/sample_rand_propagation_test.dart

This file was deleted.

Loading