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

## Unreleased

### Features

- Prevent cross-organization trace continuation ([#3567](https://github.com/getsentry/sentry-dart/pull/3567))
- By default, the SDK now extracts the organization ID from the DSN (e.g. `o123.ingest.sentry.io`) and compares it with the `sentry-org_id` value in incoming baggage headers. When the two differ, the SDK starts a fresh trace instead of continuing the foreign one. This guards against accidentally linking traces across organizations.
- New option `strictTraceContinuation` (default `false`): when enabled, both the SDK's org ID **and** the incoming baggage org ID must be present and match for a trace to be continued. Traces with a missing org ID on either side are rejected.
- New option `orgId`: allows explicitly setting the organization ID for self-hosted and Relay setups where it cannot be extracted from the DSN.

### Fixes

- Stop re-triggering hitTest in SentryUserInteractionWidget on pointerUp ([#3540](https://github.com/getsentry/sentry-dart/pull/3540))
Expand Down
12 changes: 12 additions & 0 deletions packages/dart/lib/src/protocol/dsn.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import 'package:meta/meta.dart';

/// Regex to extract the org ID from a DSN host (e.g. `o123.ingest.sentry.io` -> `123`).
final RegExp _orgIdFromHostRegExp = RegExp(r'^o(\d+)\.');

/// Extracts the organization ID from a DSN host string.
///
/// Returns the numeric org ID as a string, or `null` if the host does not
/// match the expected pattern (e.g. `o123.ingest.sentry.io`).
String? extractOrgIdFromDsnHost(String host) {
final match = _orgIdFromHostRegExp.firstMatch(host);
return match?.group(1);
}

/// The Data Source Name (DSN) tells the SDK where to send the events
@immutable
class Dsn {
Expand Down
10 changes: 10 additions & 0 deletions packages/dart/lib/src/sentry_baggage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ class SentryBaggage {
if (scope.replayId != null && scope.replayId != SentryId.empty()) {
setReplayId(scope.replayId.toString());
}
final effectiveOrgId = options.effectiveOrgId;
if (effectiveOrgId != null) {
setOrgId(effectiveOrgId);
}
}

static Map<String, String> _extractKeyValuesFromBaggageString(
Expand Down Expand Up @@ -195,6 +199,12 @@ class SentryBaggage {
return double.tryParse(sampleRand);
}

void setOrgId(String value) {
set('sentry-org_id', value);
}

String? getOrgId() => get('sentry-org_id');

void setReplayId(String value) => set('sentry-replay_id', value);

SentryId? getReplayId() {
Expand Down
33 changes: 33 additions & 0 deletions packages/dart/lib/src/sentry_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,39 @@ class SentryOptions {
/// Enabling this option may change grouping.
bool includeModuleInStackTrace = false;

/// Whether the SDK requires matching org IDs to continue an incoming trace.
///
/// When `true`, both the SDK's org ID and the incoming baggage `sentry-org_id`
/// must be present and match for a trace to be continued. When `false`
/// (the default), a mismatch between present org IDs still starts a new
/// trace, but missing org IDs on either side are tolerated.
bool strictTraceContinuation = false;

/// The organization ID for your Sentry project.
///
/// The SDK tries to extract the organization ID from the DSN automatically.
/// If it cannot be found, or if you need to override it, provide the ID
/// with this option. The organization ID is used for trace propagation and
/// for features like [strictTraceContinuation].
String? orgId;

/// The effective organization ID, preferring [orgId] over the DSN-parsed value.
@internal
String? get effectiveOrgId {
if (orgId != null) {
return orgId;
}
try {
final host = parsedDsn.uri?.host;
if (host != null) {
return extractOrgIdFromDsnHost(host);
}
} catch (_) {
// DSN may not be set or parseable
}
return null;
}

@internal
late SentryLogger logger = const NoOpSentryLogger();

Expand Down
10 changes: 10 additions & 0 deletions packages/dart/lib/src/sentry_trace_context_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class SentryTraceContextHeader {
this.sampled,
this.unknown,
this.replayId,
this.orgId,
});

final SentryId traceId;
Expand All @@ -35,6 +36,9 @@ class SentryTraceContextHeader {
@internal
SentryId? replayId;

/// The organization ID associated with this trace.
final String? orgId;

/// Deserializes a [SentryTraceContextHeader] from JSON [Map].
factory SentryTraceContextHeader.fromJson(Map<String, dynamic> data) {
final json = AccessAwareMap(data);
Expand All @@ -49,6 +53,7 @@ class SentryTraceContextHeader {
sampled: json['sampled'],
replayId:
json['replay_id'] == null ? null : SentryId.fromId(json['replay_id']),
orgId: json['org_id'] as String?,
unknown: json.notAccessed(),
);
}
Expand All @@ -66,6 +71,7 @@ class SentryTraceContextHeader {
if (sampleRate != null) 'sample_rate': sampleRate,
if (sampled != null) 'sampled': sampled,
if (replayId != null) 'replay_id': replayId.toString(),
if (orgId != null) 'org_id': orgId,
};
}

Expand Down Expand Up @@ -98,6 +104,9 @@ class SentryTraceContextHeader {
if (replayId != null) {
baggage.setReplayId(replayId.toString());
}
if (orgId != null) {
baggage.setOrgId(orgId!);
}
return baggage;
}

Expand All @@ -109,6 +118,7 @@ class SentryTraceContextHeader {
release: baggage.get('sentry-release'),
environment: baggage.get('sentry-environment'),
replayId: baggage.getReplayId(),
orgId: baggage.getOrgId(),
);
}
}
1 change: 1 addition & 0 deletions packages/dart/lib/src/sentry_tracer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ class SentryTracer extends ISentrySpan {
sampleRate: _sampleRateToString(_rootSpan.samplingDecision?.sampleRate),
sampleRand: _sampleRandToString(_rootSpan.samplingDecision?.sampleRand),
sampled: _rootSpan.samplingDecision?.sampled.toString(),
orgId: _hub.options.effectiveOrgId,
);

return _sentryTraceContextHeader;
Expand Down
15 changes: 15 additions & 0 deletions packages/dart/lib/src/sentry_transaction_context.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import 'protocol.dart';
import 'sentry_baggage.dart';
import 'sentry_options.dart';
import 'sentry_trace_origins.dart';
import 'tracing.dart';
import 'utils/tracing_utils.dart';

class SentryTransactionContext extends SentrySpanContext {
String name;
Expand Down Expand Up @@ -30,7 +32,20 @@ class SentryTransactionContext extends SentrySpanContext {
SentryTraceHeader traceHeader, {
SentryTransactionNameSource? transactionNameSource,
SentryBaggage? baggage,
SentryOptions? options,
}) {
// Validate org ID before continuing the incoming trace
if (options != null && !shouldContinueTrace(options, baggage?.getOrgId())) {
// Start a new trace instead of continuing the incoming one
return SentryTransactionContext(
name,
operation,
transactionNameSource:
transactionNameSource ?? SentryTransactionNameSource.custom,
origin: SentryTraceOrigins.manual,
);
}

final sampleRate = baggage?.getSampleRate();
final sampleRand = baggage?.getSampleRand();
return SentryTransactionContext(
Expand Down
43 changes: 43 additions & 0 deletions packages/dart/lib/src/utils/tracing_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,49 @@ bool containsTargetOrMatchesRegExp(
return false;
}

/// Determines whether an incoming trace should be continued based on org ID matching.
///
/// Returns `true` if the trace should be continued, `false` if a new trace
/// should be started instead.
///
/// The decision matrix:
/// - Both org IDs present and matching: continue
/// - Both org IDs present and different: new trace (always)
/// - One or both missing, strict=false: continue
/// - One or both missing, strict=true: new trace (unless both missing)
bool shouldContinueTrace(SentryOptions options, String? baggageOrgId) {
final sdkOrgId = options.effectiveOrgId;

// Mismatched org IDs always reject regardless of strict mode
if (sdkOrgId != null && baggageOrgId != null && sdkOrgId != baggageOrgId) {
options.log(
SentryLevel.debug,
"Not continuing trace because org IDs don't match "
'(incoming baggage: $baggageOrgId, SDK: $sdkOrgId)',
);
return false;
}

if (options.strictTraceContinuation) {
// Both missing is OK
if (sdkOrgId == null && baggageOrgId == null) {
return true;
}
// One missing means reject
if (sdkOrgId == null || baggageOrgId == null) {
options.log(
SentryLevel.debug,
'Starting a new trace because strict trace continuation is enabled '
'but one org ID is missing '
'(incoming baggage: $baggageOrgId, SDK: $sdkOrgId)',
);
return false;
}
}

return true;
}

bool isValidSampleRate(double? sampleRate) {
if (sampleRate == null) {
return false;
Expand Down
Loading
Loading