diff --git a/metrics/metrics-android.yml b/metrics/metrics-android.yml index 43911be7a6..a8dffd5ee1 100644 --- a/metrics/metrics-android.yml +++ b/metrics/metrics-android.yml @@ -13,4 +13,4 @@ startupTimeTest: binarySizeTest: diffMin: 900 KiB - diffMax: 1300 KiB + diffMax: 1350 KiB diff --git a/packages/dart/lib/sentry.dart b/packages/dart/lib/sentry.dart index b3db224cf3..5d6e4a83dd 100644 --- a/packages/dart/lib/sentry.dart +++ b/packages/dart/lib/sentry.dart @@ -22,6 +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'; +// ignore: invalid_export_of_internal_element export 'src/protocol.dart'; export 'src/protocol/sentry_feature_flag.dart'; export 'src/protocol/sentry_feature_flags.dart'; @@ -59,6 +60,7 @@ export 'src/utils.dart'; export 'src/utils/http_header_utils.dart'; // ignore: invalid_export_of_internal_element export 'src/utils/http_sanitizer.dart'; +// ignore: invalid_export_of_internal_element export 'src/utils/tracing_utils.dart'; // ignore: invalid_export_of_internal_element export 'src/utils/url_details.dart'; diff --git a/packages/dart/lib/src/protocol/dsn.dart b/packages/dart/lib/src/protocol/dsn.dart index c3ec5093c8..edae16cbd8 100644 --- a/packages/dart/lib/src/protocol/dsn.dart +++ b/packages/dart/lib/src/protocol/dsn.dart @@ -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 { diff --git a/packages/dart/lib/src/sentry_baggage.dart b/packages/dart/lib/src/sentry_baggage.dart index 37232aa4d9..42f8c6fe1c 100644 --- a/packages/dart/lib/src/sentry_baggage.dart +++ b/packages/dart/lib/src/sentry_baggage.dart @@ -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 _extractKeyValuesFromBaggageString( @@ -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() { diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 9f793eaeb1..40f84be67e 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -600,6 +600,43 @@ 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. + /// + /// Empty or whitespace-only explicit [orgId] values are treated as unset + /// and fall back to the DSN. + @internal + String? get effectiveOrgId { + final explicit = orgId?.trim(); + if (explicit != null && explicit.isNotEmpty) { + return explicit; + } + 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(); diff --git a/packages/dart/lib/src/sentry_trace_context_header.dart b/packages/dart/lib/src/sentry_trace_context_header.dart index ce65403244..890aa858b2 100644 --- a/packages/dart/lib/src/sentry_trace_context_header.dart +++ b/packages/dart/lib/src/sentry_trace_context_header.dart @@ -3,6 +3,7 @@ import 'package:meta/meta.dart'; import '../sentry.dart'; import 'protocol/access_aware_map.dart'; import 'utils/sample_rate_format.dart'; +import 'utils/type_safe_map_access.dart'; class SentryTraceContextHeader { SentryTraceContextHeader( @@ -17,6 +18,7 @@ class SentryTraceContextHeader { this.sampled, this.unknown, this.replayId, + this.orgId, }); final SentryId traceId; @@ -35,6 +37,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 data) { final json = AccessAwareMap(data); @@ -49,6 +54,7 @@ class SentryTraceContextHeader { sampled: json['sampled'], replayId: json['replay_id'] == null ? null : SentryId.fromId(json['replay_id']), + orgId: json.getValueOrNull('org_id'), unknown: json.notAccessed(), ); } @@ -66,6 +72,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, }; } @@ -98,6 +105,9 @@ class SentryTraceContextHeader { if (replayId != null) { baggage.setReplayId(replayId.toString()); } + if (orgId != null) { + baggage.setOrgId(orgId!); + } return baggage; } @@ -109,6 +119,7 @@ class SentryTraceContextHeader { release: baggage.get('sentry-release'), environment: baggage.get('sentry-environment'), replayId: baggage.getReplayId(), + orgId: baggage.getOrgId(), ); } @@ -134,6 +145,7 @@ class SentryTraceContextHeader { sampleRand: span.samplingDecision.sampleRand?.toString(), sampled: span.samplingDecision.sampled.toString(), replayId: replayId, + orgId: options.effectiveOrgId, ); } } diff --git a/packages/dart/lib/src/sentry_tracer.dart b/packages/dart/lib/src/sentry_tracer.dart index 2ba65c88d1..52d5aec67e 100644 --- a/packages/dart/lib/src/sentry_tracer.dart +++ b/packages/dart/lib/src/sentry_tracer.dart @@ -393,6 +393,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; diff --git a/packages/dart/lib/src/sentry_transaction_context.dart b/packages/dart/lib/src/sentry_transaction_context.dart index 5002cb9b40..a52314b448 100644 --- a/packages/dart/lib/src/sentry_transaction_context.dart +++ b/packages/dart/lib/src/sentry_transaction_context.dart @@ -1,7 +1,10 @@ import 'protocol.dart'; +import 'sentry.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; @@ -24,13 +27,34 @@ class SentryTransactionContext extends SentrySpanContext { operation: operation, ); + /// Creates a [SentryTransactionContext] from an incoming [traceHeader] and + /// optional [baggage]. + /// + /// Validates the incoming trace's `sentry-org_id` against the SDK's + /// organization ID (see [SentryOptions.strictTraceContinuation]). When the + /// trace should not be continued, a new trace is started instead. + /// + /// If [options] is not provided, the current hub's options are used. factory SentryTransactionContext.fromSentryTrace( String name, String operation, SentryTraceHeader traceHeader, { SentryTransactionNameSource? transactionNameSource, SentryBaggage? baggage, + SentryOptions? options, }) { + final effectiveOptions = options ?? Sentry.currentHub.options; + + if (!shouldContinueTrace(effectiveOptions, baggage?.getOrgId())) { + return SentryTransactionContext( + name, + operation, + transactionNameSource: + transactionNameSource ?? SentryTransactionNameSource.custom, + origin: SentryTraceOrigins.manual, + ); + } + final sampleRate = baggage?.getSampleRate(); final sampleRand = baggage?.getSampleRand(); return SentryTransactionContext( diff --git a/packages/dart/lib/src/utils/tracing_utils.dart b/packages/dart/lib/src/utils/tracing_utils.dart index e89ab3a92d..1a3c21aeb6 100644 --- a/packages/dart/lib/src/utils/tracing_utils.dart +++ b/packages/dart/lib/src/utils/tracing_utils.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + import '../../sentry.dart'; SentryTraceHeader generateSentryTraceHeader( @@ -132,6 +134,53 @@ 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; + // Treat empty/whitespace-only baggage org IDs as absent + final trimmedBaggageOrgId = baggageOrgId?.trim(); + baggageOrgId = (trimmedBaggageOrgId != null && trimmedBaggageOrgId.isNotEmpty) + ? trimmedBaggageOrgId + : null; + + // 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) { + if (sdkOrgId == null && baggageOrgId == null) { + return true; + } + 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; diff --git a/packages/dart/test/strict_trace_continuation_test.dart b/packages/dart/test/strict_trace_continuation_test.dart new file mode 100644 index 0000000000..00e99f756e --- /dev/null +++ b/packages/dart/test/strict_trace_continuation_test.dart @@ -0,0 +1,385 @@ +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('extractOrgIdFromDsnHost', () { + test('extracts org id from standard host', () { + expect(extractOrgIdFromDsnHost('o123.ingest.sentry.io'), '123'); + }); + + test('extracts single digit org id', () { + expect(extractOrgIdFromDsnHost('o1.ingest.us.sentry.io'), '1'); + }); + + test('extracts large org id', () { + expect(extractOrgIdFromDsnHost('o9999999.ingest.sentry.io'), '9999999'); + }); + + test('returns null for host without org prefix', () { + expect(extractOrgIdFromDsnHost('sentry.io'), isNull); + }); + + test('returns null for localhost', () { + expect(extractOrgIdFromDsnHost('localhost'), isNull); + }); + + test('returns null for empty string', () { + expect(extractOrgIdFromDsnHost(''), isNull); + }); + + test('returns null for non-numeric org id', () { + expect(extractOrgIdFromDsnHost('oabc.ingest.sentry.io'), isNull); + }); + }); + + group('SentryOptions', () { + group('effectiveOrgId', () { + test('returns null when neither orgId nor DSN org id are set', () { + final options = defaultTestOptions(); + expect(options.effectiveOrgId, isNull); + }); + + test('returns explicit orgId when set', () { + final options = defaultTestOptions()..orgId = '456'; + expect(options.effectiveOrgId, '456'); + }); + + test('returns DSN-extracted org id when orgId is not set', () { + final options = SentryOptions( + dsn: 'https://public@o123.ingest.sentry.io/1', + )..automatedTestMode = true; + expect(options.effectiveOrgId, '123'); + }); + + test('prefers explicit orgId over DSN-extracted org id', () { + final options = + SentryOptions(dsn: 'https://public@o123.ingest.sentry.io/1') + ..automatedTestMode = true + ..orgId = '456'; + expect(options.effectiveOrgId, '456'); + }); + + test('empty explicit orgId falls back to DSN', () { + final options = + SentryOptions(dsn: 'https://public@o123.ingest.sentry.io/1') + ..automatedTestMode = true + ..orgId = ''; + expect(options.effectiveOrgId, '123'); + }); + + test('whitespace-only explicit orgId falls back to DSN', () { + final options = + SentryOptions(dsn: 'https://public@o123.ingest.sentry.io/1') + ..automatedTestMode = true + ..orgId = ' '; + expect(options.effectiveOrgId, '123'); + }); + + test('trims whitespace from explicit orgId', () { + final options = defaultTestOptions()..orgId = ' 456 '; + expect(options.effectiveOrgId, '456'); + }); + }); + + test('strictTraceContinuation defaults to false', () { + final options = defaultTestOptions(); + expect(options.strictTraceContinuation, isFalse); + }); + + test('orgId defaults to null', () { + final options = defaultTestOptions(); + expect(options.orgId, isNull); + }); + }); + + group('shouldContinueTrace', () { + test('returns true when both org IDs are null', () { + final options = defaultTestOptions(); + expect(shouldContinueTrace(options, null), isTrue); + }); + + test('returns true when org IDs match', () { + final options = defaultTestOptions()..orgId = '123'; + expect(shouldContinueTrace(options, '123'), isTrue); + }); + + test('returns false when org IDs do not match', () { + final options = defaultTestOptions()..orgId = '123'; + expect(shouldContinueTrace(options, '456'), isFalse); + }); + + test('treats empty baggage org ID as missing', () { + final options = defaultTestOptions() + ..orgId = '123' + ..strictTraceContinuation = true; + expect(shouldContinueTrace(options, ''), isFalse); + }); + + test('treats whitespace-only baggage org ID as missing', () { + final options = defaultTestOptions() + ..orgId = '123' + ..strictTraceContinuation = true; + expect(shouldContinueTrace(options, ' '), isFalse); + }); + + test('trims whitespace from baggage org ID before comparison', () { + final options = defaultTestOptions()..orgId = '123'; + expect(shouldContinueTrace(options, ' 123 '), isTrue); + }); + + group('when strictTraceContinuation is false', () { + test('continues trace when baggage org ID is missing', () { + final options = defaultTestOptions() + ..orgId = '123' + ..strictTraceContinuation = false; + expect(shouldContinueTrace(options, null), isTrue); + }); + + test('continues trace when SDK org ID is missing', () { + final options = defaultTestOptions()..strictTraceContinuation = false; + expect(shouldContinueTrace(options, '123'), isTrue); + }); + }); + + group('when strictTraceContinuation is true', () { + test('starts new trace when baggage org ID is missing', () { + final options = defaultTestOptions() + ..orgId = '123' + ..strictTraceContinuation = true; + expect(shouldContinueTrace(options, null), isFalse); + }); + + test('starts new trace when SDK org ID is missing', () { + final options = defaultTestOptions()..strictTraceContinuation = true; + expect(shouldContinueTrace(options, '123'), isFalse); + }); + + test('continues trace when both org IDs are missing', () { + final options = defaultTestOptions()..strictTraceContinuation = true; + expect(shouldContinueTrace(options, null), isTrue); + }); + + test('continues trace when org IDs match', () { + final options = defaultTestOptions() + ..orgId = '123' + ..strictTraceContinuation = true; + expect(shouldContinueTrace(options, '123'), isTrue); + }); + + test('starts new trace when org IDs do not match', () { + final options = defaultTestOptions() + ..orgId = '123' + ..strictTraceContinuation = true; + expect(shouldContinueTrace(options, '456'), isFalse); + }); + }); + }); + + group('SentryBaggage', () { + test('sets and gets org_id', () { + final baggage = SentryBaggage({}); + baggage.setOrgId('123'); + expect(baggage.getOrgId(), '123'); + }); + + test('returns null for missing org_id', () { + final baggage = SentryBaggage({}); + expect(baggage.getOrgId(), isNull); + }); + + test('setValuesFromScope includes org_id when available', () { + final options = SentryOptions( + dsn: 'https://public@o123.ingest.sentry.io/1', + )..automatedTestMode = true; + final scope = Scope(options); + final baggage = SentryBaggage({}); + + baggage.setValuesFromScope(scope, options); + expect(baggage.getOrgId(), '123'); + }); + + test('setValuesFromScope includes explicit orgId', () { + final options = defaultTestOptions()..orgId = '456'; + final scope = Scope(options); + final baggage = SentryBaggage({}); + + baggage.setValuesFromScope(scope, options); + expect(baggage.getOrgId(), '456'); + }); + + test('setValuesFromScope does not include org_id when not available', () { + final options = defaultTestOptions(); + final scope = Scope(options); + final baggage = SentryBaggage({}); + + baggage.setValuesFromScope(scope, options); + expect(baggage.getOrgId(), isNull); + }); + + test('org_id is included in header string', () { + final baggage = SentryBaggage({}); + baggage.setOrgId('123'); + expect(baggage.toHeaderString(), contains('sentry-org_id=123')); + }); + }); + + group('SentryTraceContextHeader', () { + test('includes orgId in toBaggage', () { + final context = SentryTraceContextHeader( + SentryId.newId(), + 'publicKey', + orgId: '123', + ); + final baggage = context.toBaggage(); + expect(baggage.getOrgId(), '123'); + }); + + test('does not include orgId in toBaggage when null', () { + final context = SentryTraceContextHeader(SentryId.newId(), 'publicKey'); + final baggage = context.toBaggage(); + expect(baggage.getOrgId(), isNull); + }); + + test('includes orgId in toJson', () { + final context = SentryTraceContextHeader( + SentryId.newId(), + 'publicKey', + orgId: '123', + ); + final json = context.toJson(); + expect(json['org_id'], '123'); + }); + + test('reads orgId from fromJson', () { + final context = SentryTraceContextHeader.fromJson({ + 'trace_id': SentryId.newId().toString(), + 'public_key': 'publicKey', + 'org_id': '123', + }); + expect(context.orgId, '123'); + }); + + test('reads orgId from fromBaggage', () { + final baggage = SentryBaggage({}); + baggage.setTraceId(SentryId.newId().toString()); + baggage.setPublicKey('publicKey'); + baggage.setOrgId('123'); + + final context = SentryTraceContextHeader.fromBaggage(baggage); + expect(context.orgId, '123'); + }); + }); + + group('SentryTransactionContext', () { + final traceId = SentryId.fromId('12312012123120121231201212312012'); + final spanId = SpanId.fromId('1121201211212012'); + + group('fromSentryTrace', () { + test('continues trace when org IDs match', () { + final options = defaultTestOptions()..orgId = '123'; + final header = SentryTraceHeader(traceId, spanId, sampled: true); + final baggage = SentryBaggage({})..setOrgId('123'); + + final context = SentryTransactionContext.fromSentryTrace( + 'name', + 'op', + header, + baggage: baggage, + options: options, + ); + + expect(context.traceId, traceId); + expect(context.parentSpanId, spanId); + }); + + test('starts new trace when org IDs do not match', () { + final options = defaultTestOptions()..orgId = '123'; + final header = SentryTraceHeader(traceId, spanId, sampled: true); + final baggage = SentryBaggage({})..setOrgId('456'); + + final context = SentryTransactionContext.fromSentryTrace( + 'name', + 'op', + header, + baggage: baggage, + options: options, + ); + + expect(context.traceId, isNot(traceId)); + expect(context.parentSpanId, isNull); + }); + + test( + 'falls back to NoOp hub options when options is null and Sentry is not initialized', + () { + final header = SentryTraceHeader(traceId, spanId, sampled: true); + final baggage = SentryBaggage({})..setOrgId('456'); + + final context = SentryTransactionContext.fromSentryTrace( + 'name', + 'op', + header, + baggage: baggage, + ); + + // NoOp hub has no org ID configured, so there's nothing to mismatch + // against and the trace should be continued. + expect(context.traceId, traceId); + expect(context.parentSpanId, spanId); + }); + + group('when strictTraceContinuation is true', () { + test('starts new trace when baggage org ID is missing', () { + final options = defaultTestOptions() + ..orgId = '123' + ..strictTraceContinuation = true; + final header = SentryTraceHeader(traceId, spanId, sampled: true); + + final context = SentryTransactionContext.fromSentryTrace( + 'name', + 'op', + header, + options: options, + ); + + expect(context.traceId, isNot(traceId)); + expect(context.parentSpanId, isNull); + }); + + test('starts new trace when SDK org ID is missing', () { + final options = defaultTestOptions()..strictTraceContinuation = true; + final header = SentryTraceHeader(traceId, spanId, sampled: true); + final baggage = SentryBaggage({})..setOrgId('123'); + + final context = SentryTransactionContext.fromSentryTrace( + 'name', + 'op', + header, + baggage: baggage, + options: options, + ); + + expect(context.traceId, isNot(traceId)); + expect(context.parentSpanId, isNull); + }); + + test('continues trace when both org IDs are missing', () { + final options = defaultTestOptions()..strictTraceContinuation = true; + final header = SentryTraceHeader(traceId, spanId, sampled: true); + + final context = SentryTransactionContext.fromSentryTrace( + 'name', + 'op', + header, + options: options, + ); + + expect(context.traceId, traceId); + expect(context.parentSpanId, spanId); + }); + }); + }); + }); +} diff --git a/packages/flutter/lib/src/native/java/sentry_native_java_init.dart b/packages/flutter/lib/src/native/java/sentry_native_java_init.dart index bb709dccf9..045dbadfc4 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java_init.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java_init.dart @@ -259,6 +259,10 @@ void configureAndroidOptions({ androidOptions.setSendClientReports(options.sendClientReports); androidOptions.setMaxAttachmentSize(options.maxAttachmentSize); + androidOptions.setStrictTraceContinuation(options.strictTraceContinuation); + androidOptions + // ignore: invalid_use_of_internal_member + .setOrgId(options.effectiveOrgId?.toJString()?..releasedBy(arena)); androidOptions .setConnectionTimeoutMillis(options.connectionTimeout.inMilliseconds); androidOptions.setReadTimeoutMillis(options.readTimeout.inMilliseconds);