Skip to content
Draft
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
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
4 changes: 2 additions & 2 deletions packages/dart/lib/sentry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export 'src/noop_isolate_error_integration.dart'
if (dart.library.io) 'src/isolate_error_integration.dart';
// ignore: invalid_export_of_internal_element
export 'src/performance_collector.dart';
export 'src/protocol.dart';
export 'src/protocol.dart' hide extractOrgIdFromDsnHost;
export 'src/protocol/sentry_feature_flag.dart';
export 'src/protocol/sentry_feature_flags.dart';
export 'src/protocol/sentry_feedback.dart';
Expand Down Expand Up @@ -57,7 +57,7 @@ export 'src/utils.dart';
export 'src/utils/http_header_utils.dart';
// ignore: invalid_export_of_internal_element
export 'src/utils/http_sanitizer.dart';
export 'src/utils/tracing_utils.dart';
export 'src/utils/tracing_utils.dart' hide shouldContinueTrace;
// ignore: invalid_export_of_internal_element
export 'src/utils/url_details.dart';
// ignore: invalid_export_of_internal_element
Expand Down
13 changes: 13 additions & 0 deletions packages/dart/lib/src/protocol/dsn.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
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`).
@internal
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
34 changes: 34 additions & 0 deletions packages/dart/lib/src/sentry_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:meta/meta.dart';

import '../sentry.dart';
import 'client_reports/client_report_recorder.dart';
import 'protocol/dsn.dart';
import 'client_reports/noop_client_report_recorder.dart';
import 'diagnostic_log.dart';
import 'environment/environment_variables.dart';
Expand Down Expand Up @@ -568,6 +569,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
94 changes: 75 additions & 19 deletions packages/dart/lib/src/utils/tracing_utils.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import 'package:meta/meta.dart';

import '../../sentry.dart';

SentryTraceHeader generateSentryTraceHeader(
{SentryId? traceId, SpanId? spanId, bool? sampled}) {
SentryTraceHeader generateSentryTraceHeader({
SentryId? traceId,
SpanId? spanId,
bool? sampled,
}) {
traceId ??= SentryId.newId();
spanId ??= SpanId.newId();
return SentryTraceHeader(traceId, spanId, sampled: sampled);
Expand All @@ -17,10 +22,7 @@ void addTracingHeadersToHttpHeader(
addW3CHeaderFromSpan(span, headers);
}
addSentryTraceHeaderFromSpan(span, headers);
addBaggageHeaderFromSpan(
span,
headers,
);
addBaggageHeaderFromSpan(span, headers);
} else {
if (hub.options.propagateTraceparent) {
addW3CHeaderFromScope(hub.scope, headers);
Expand All @@ -37,18 +39,24 @@ void addSentryTraceHeaderFromScope(Scope scope, Map<String, dynamic> headers) {
}

void addSentryTraceHeaderFromSpan(
InstrumentationSpan span, Map<String, dynamic> headers) {
InstrumentationSpan span,
Map<String, dynamic> headers,
) {
final traceHeader = span.toSentryTrace();
headers[traceHeader.name] = traceHeader.value;
}

void addSentryTraceHeader(
SentryTraceHeader traceHeader, Map<String, dynamic> headers) {
SentryTraceHeader traceHeader,
Map<String, dynamic> headers,
) {
headers[traceHeader.name] = traceHeader.value;
}

void addW3CHeaderFromSpan(
InstrumentationSpan span, Map<String, dynamic> headers) {
InstrumentationSpan span,
Map<String, dynamic> headers,
) {
final traceHeader = span.toSentryTrace();
_addW3CHeaderFromSentryTrace(traceHeader, headers);
}
Expand All @@ -60,7 +68,9 @@ void addW3CHeaderFromScope(Scope scope, Map<String, dynamic> headers) {
}

void _addW3CHeaderFromSentryTrace(
SentryTraceHeader traceHeader, Map<String, dynamic> headers) {
SentryTraceHeader traceHeader,
Map<String, dynamic> headers,
) {
headers['traceparent'] = formatAsW3CHeader(traceHeader);
}

Expand All @@ -78,23 +88,23 @@ void addBaggageHeaderFromScope(Scope scope, Map<String, dynamic> headers) {
}

void addBaggageHeaderFromSpan(
InstrumentationSpan span, Map<String, dynamic> headers) {
InstrumentationSpan span,
Map<String, dynamic> headers,
) {
final baggage = span.toBaggageHeader();
if (baggage != null) {
addBaggageHeader(baggage, headers);
}
}

void addBaggageHeader(
SentryBaggageHeader baggage, Map<String, dynamic> headers) {
SentryBaggageHeader baggage,
Map<String, dynamic> headers,
) {
final currentValue = headers[baggage.name] as String? ?? '';

final currentBaggage = SentryBaggage.fromHeader(
currentValue,
);
final sentryBaggage = SentryBaggage.fromHeader(
baggage.value,
);
final currentBaggage = SentryBaggage.fromHeader(currentValue);
final sentryBaggage = SentryBaggage.fromHeader(baggage.value);

// overwrite sentry's keys https://develop.sentry.dev/sdk/performance/dynamic-sampling-context/#baggage
final filteredBaggageHeader = Map.from(currentBaggage.keyValues);
Expand All @@ -111,7 +121,9 @@ void addBaggageHeader(
}

bool containsTargetOrMatchesRegExp(
List<String> tracePropagationTargets, String url) {
List<String> tracePropagationTargets,
String url,
) {
if (tracePropagationTargets.isEmpty) {
return false;
}
Expand All @@ -132,6 +144,50 @@ 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)
@internal
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