Skip to content

Commit bdc33d9

Browse files
committed
feat: Implement strict trace continuation
- Parse org_id from DSN host (e.g. `o123.ingest.sentry.io` → `123`) - Add `strictTraceContinuation` and `orgId` options to Options - Add `effectiveOrgId` computed property (explicit orgId > DSN > nil) - Propagate `sentry-org_id` in Baggage and TraceContext - Add `shouldContinueTrace` to SentryPropagationContext implementing the decision matrix for trace continuation validation Spec: https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation
1 parent 8dcd76a commit bdc33d9

14 files changed

Lines changed: 634 additions & 11 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
### Features
66

7+
- Prevent cross-organization trace continuation (#7705)
8+
- 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.
9+
- 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.
10+
- New option `orgId`: allows explicitly setting the organization ID for self-hosted and Relay setups where it cannot be extracted from the DSN.
711
- Add `SentrySDK.lastRunStatus` to distinguish unknown, no-crash and crash (#7469)
812

913
## 9.7.0

Sources/Sentry/Public/SentryBaggage.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ NS_SWIFT_NAME(Baggage)
5959

6060
@property (nullable, nonatomic, strong) NSString *replayId;
6161

62+
/**
63+
* The organization ID extracted from the DSN or configured explicitly.
64+
*/
65+
@property (nullable, nonatomic, readonly) NSString *orgId;
66+
6267
- (instancetype)initWithTraceId:(SentryId *)traceId
6368
publicKey:(NSString *)publicKey
6469
releaseName:(nullable NSString *)releaseName
@@ -78,6 +83,17 @@ NS_SWIFT_NAME(Baggage)
7883
sampled:(nullable NSString *)sampled
7984
replayId:(nullable NSString *)replayId;
8085

86+
- (instancetype)initWithTraceId:(SentryId *)traceId
87+
publicKey:(NSString *)publicKey
88+
releaseName:(nullable NSString *)releaseName
89+
environment:(nullable NSString *)environment
90+
transaction:(nullable NSString *)transaction
91+
sampleRate:(nullable NSString *)sampleRate
92+
sampleRand:(nullable NSString *)sampleRand
93+
sampled:(nullable NSString *)sampled
94+
replayId:(nullable NSString *)replayId
95+
orgId:(nullable NSString *)orgId;
96+
8197
- (NSString *)toHTTPHeaderWithOriginalBaggage:(NSDictionary *_Nullable)originalBaggage;
8298

8399
@end

Sources/Sentry/Public/SentryTraceContext.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ NS_SWIFT_NAME(TraceContext)
6565
*/
6666
@property (nullable, nonatomic, readonly) NSString *replayId;
6767

68+
/**
69+
* The organization ID extracted from the DSN or configured explicitly.
70+
*/
71+
@property (nullable, nonatomic, readonly) NSString *orgId;
72+
6873
/**
6974
* Create a SentryBaggage with the information of this SentryTraceContext.
7075
*/

Sources/Sentry/SentryBaggage.m

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
2525
sampleRate:sampleRate
2626
sampleRand:nil
2727
sampled:sampled
28-
replayId:replayId];
28+
replayId:replayId
29+
orgId:nil];
2930
}
3031

3132
- (instancetype)initWithTraceId:(SentryId *)traceId
@@ -38,6 +39,29 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
3839
sampled:(nullable NSString *)sampled
3940
replayId:(nullable NSString *)replayId
4041
{
42+
return [self initWithTraceId:traceId
43+
publicKey:publicKey
44+
releaseName:releaseName
45+
environment:environment
46+
transaction:transaction
47+
sampleRate:sampleRate
48+
sampleRand:sampleRand
49+
sampled:sampled
50+
replayId:replayId
51+
orgId:nil];
52+
}
53+
54+
- (instancetype)initWithTraceId:(SentryId *)traceId
55+
publicKey:(NSString *)publicKey
56+
releaseName:(nullable NSString *)releaseName
57+
environment:(nullable NSString *)environment
58+
transaction:(nullable NSString *)transaction
59+
sampleRate:(nullable NSString *)sampleRate
60+
sampleRand:(nullable NSString *)sampleRand
61+
sampled:(nullable NSString *)sampled
62+
replayId:(nullable NSString *)replayId
63+
orgId:(nullable NSString *)orgId
64+
{
4165

4266
if (self = [super init]) {
4367
_traceId = traceId;
@@ -49,6 +73,7 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
4973
_sampleRand = sampleRand;
5074
_sampled = sampled;
5175
_replayId = replayId;
76+
_orgId = orgId;
5277
}
5378

5479
return self;
@@ -90,6 +115,10 @@ - (NSString *)toHTTPHeaderWithOriginalBaggage:(NSDictionary *_Nullable)originalB
90115
[information setValue:_replayId forKey:@"sentry-replay_id"];
91116
}
92117

118+
if (_orgId != nil) {
119+
[information setValue:_orgId forKey:@"sentry-org_id"];
120+
}
121+
93122
return [SentryBaggageSerialization encodeDictionary:information];
94123
}
95124

Sources/Sentry/SentryOptionsInternal.m

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,13 @@ + (BOOL)validateOptions:(NSDictionary<NSString *, id> *)options
304304
block:^(BOOL value) { sentryOptions.enableMetricKitRawPayload = value; }];
305305
#endif // SENTRY_HAS_METRIC_KIT
306306

307+
[self setBool:options[@"strictTraceContinuation"]
308+
block:^(BOOL value) { sentryOptions.strictTraceContinuation = value; }];
309+
310+
if ([options[@"orgId"] isKindOfClass:[NSString class]]) {
311+
sentryOptions.orgId = SENTRY_UNWRAP_NULLABLE(NSString, options[@"orgId"]);
312+
}
313+
307314
[self setBool:options[@"enableSpotlight"]
308315
block:^(BOOL value) { sentryOptions.enableSpotlight = value; }];
309316

Sources/Sentry/SentryTraceContext.m

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
3333
sampleRate:sampleRate
3434
sampleRand:nil
3535
sampled:sampled
36-
replayId:replayId];
36+
replayId:replayId
37+
orgId:nil];
3738
}
3839

3940
- (instancetype)initWithTraceId:(SentryId *)traceId
@@ -45,6 +46,29 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
4546
sampleRand:(nullable NSString *)sampleRand
4647
sampled:(nullable NSString *)sampled
4748
replayId:(nullable NSString *)replayId
49+
{
50+
return [self initWithTraceId:traceId
51+
publicKey:publicKey
52+
releaseName:releaseName
53+
environment:environment
54+
transaction:transaction
55+
sampleRate:sampleRate
56+
sampleRand:sampleRand
57+
sampled:sampled
58+
replayId:replayId
59+
orgId:nil];
60+
}
61+
62+
- (instancetype)initWithTraceId:(SentryId *)traceId
63+
publicKey:(NSString *)publicKey
64+
releaseName:(nullable NSString *)releaseName
65+
environment:(nullable NSString *)environment
66+
transaction:(nullable NSString *)transaction
67+
sampleRate:(nullable NSString *)sampleRate
68+
sampleRand:(nullable NSString *)sampleRand
69+
sampled:(nullable NSString *)sampled
70+
replayId:(nullable NSString *)replayId
71+
orgId:(nullable NSString *)orgId
4872
{
4973
if (self = [super init]) {
5074
_traceId = traceId;
@@ -56,6 +80,7 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
5680
_sampleRate = sampleRate;
5781
_sampled = sampled;
5882
_replayId = replayId;
83+
_orgId = orgId;
5984
}
6085
return self;
6186
}
@@ -102,7 +127,8 @@ - (nullable instancetype)initWithTracer:(SentryTracer *)tracer
102127
sampleRate:serializedSampleRate
103128
sampleRand:serializedSampleRand
104129
sampled:sampled
105-
replayId:scope.replayId];
130+
replayId:scope.replayId
131+
orgId:options.effectiveOrgId];
106132
}
107133

108134
- (instancetype)initWithTraceId:(SentryId *)traceId
@@ -118,7 +144,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
118144
sampleRate:nil
119145
sampleRand:nil
120146
sampled:nil
121-
replayId:replayId];
147+
replayId:replayId
148+
orgId:options.effectiveOrgId];
122149
}
123150

124151
- (nullable instancetype)initWithDict:(NSDictionary<NSString *, id> *)dictionary
@@ -143,7 +170,8 @@ - (nullable instancetype)initWithDict:(NSDictionary<NSString *, id> *)dictionary
143170
sampleRate:dictionary[@"sample_rate"]
144171
sampleRand:dictionary[@"sample_rand"]
145172
sampled:dictionary[@"sampled"]
146-
replayId:dictionary[@"replay_id"]];
173+
replayId:dictionary[@"replay_id"]
174+
orgId:dictionary[@"org_id"]];
147175
}
148176

149177
- (SentryBaggage *)toBaggage
@@ -156,7 +184,8 @@ - (SentryBaggage *)toBaggage
156184
sampleRate:_sampleRate
157185
sampleRand:_sampleRand
158186
sampled:_sampled
159-
replayId:_replayId];
187+
replayId:_replayId
188+
orgId:_orgId];
160189
return result;
161190
}
162191

@@ -193,6 +222,10 @@ - (SentryBaggage *)toBaggage
193222
[result setValue:_replayId forKey:@"replay_id"];
194223
}
195224

225+
if (_orgId != nil) {
226+
[result setValue:_orgId forKey:@"org_id"];
227+
}
228+
196229
return result;
197230
}
198231

Sources/Sentry/include/SentryTraceContext+Private.h

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ NS_ASSUME_NONNULL_BEGIN
3535
sampled:(nullable NSString *)sampled
3636
replayId:(nullable NSString *)replayId;
3737

38+
/**
39+
* Initializes a SentryTraceContext with given properties including org ID.
40+
*/
41+
- (instancetype)initWithTraceId:(SentryId *)traceId
42+
publicKey:(NSString *)publicKey
43+
releaseName:(nullable NSString *)releaseName
44+
environment:(nullable NSString *)environment
45+
transaction:(nullable NSString *)transaction
46+
sampleRate:(nullable NSString *)sampleRate
47+
sampleRand:(nullable NSString *)sampleRand
48+
sampled:(nullable NSString *)sampled
49+
replayId:(nullable NSString *)replayId
50+
orgId:(nullable NSString *)orgId;
51+
3852
/**
3953
* Initializes a SentryTraceContext with data from scope and options.
4054
*/

Sources/Swift/Options.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,35 @@
632632
/// https://spotlightjs.com/
633633
@objc public var spotlightUrl = "http://localhost:8969/stream"
634634

635+
/// If set to `true`, the SDK will only continue a trace if the organization ID of the incoming
636+
/// trace found in the baggage header matches the organization ID of the current Sentry client.
637+
///
638+
/// The client's organization ID is extracted from the DSN or can be set with the `orgId` option.
639+
///
640+
/// If the organization IDs do not match, the SDK will start a new trace instead of continuing
641+
/// the incoming one. This is useful to prevent traces of unknown third-party services from being
642+
/// continued in your application.
643+
///
644+
/// @note Default value is @c false.
645+
@objc public var strictTraceContinuation: Bool = false
646+
647+
/// The organization ID for your Sentry project.
648+
///
649+
/// The SDK will try to extract the organization ID from the DSN. If it cannot be found, or if
650+
/// you need to override it, you can provide the ID with this option. The organization ID is used
651+
/// for trace propagation and for features like `strictTraceContinuation`.
652+
@objc public var orgId: String?
653+
654+
/// Returns the effective organization ID, preferring the explicit `orgId` option over the
655+
/// DSN-extracted value.
656+
@_spi(Private) @objc
657+
public var effectiveOrgId: String? {
658+
if let orgId = orgId, !orgId.isEmpty {
659+
return orgId
660+
}
661+
return parsedDsn?.orgId
662+
}
663+
635664
/// Options for experimental features that are subject to change.
636665
@objc public var experimental = SentryExperimentalOptions()
637666

Sources/Swift/SentryDsn.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,22 @@ public final class SentryDsn: NSObject {
106106
return endpoint
107107
}
108108

109+
/// Extracts the organization ID from the DSN host.
110+
///
111+
/// For example, given a DSN with host `o123.ingest.sentry.io`, this returns `"123"`.
112+
/// Returns `nil` if the host does not match the expected pattern.
113+
@_spi(Private) @objc
114+
public var orgId: String? {
115+
guard let host = url.host else { return nil }
116+
let pattern = "^o(\\d+)\\."
117+
guard let regex = try? NSRegularExpression(pattern: pattern),
118+
let match = regex.firstMatch(in: host, range: NSRange(host.startIndex..., in: host)),
119+
let range = Range(match.range(at: 1), in: host) else {
120+
return nil
121+
}
122+
return String(host[range])
123+
}
124+
109125
/// Returns the base API endpoint URL for this DSN.
110126
/// - Returns: The base endpoint URL.
111127
private func getBaseEndpoint() -> URL {

Sources/Swift/State/SentryPropagationContext.swift

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
@objc public var traceHeader: TraceHeader {
88
TraceHeader(trace: traceId, spanId: spanId, sampled: .no)
99
}
10-
10+
1111
@objc public override init() {
1212
self.traceId = SentryId()
1313
self.spanId = SpanId()
@@ -17,12 +17,59 @@
1717
self.traceId = traceId
1818
self.spanId = spanId
1919
}
20-
20+
2121
@objc public func traceContextForEvent() -> [String: String] {
2222
[
2323
"span_id": spanId.sentrySpanIdString,
2424
"trace_id": traceId.sentryIdString
2525
]
2626
}
27+
28+
/// Determines whether a trace should be continued based on the incoming baggage org ID
29+
/// and the SDK options.
30+
///
31+
/// Decision matrix:
32+
/// | Baggage org | SDK org | strict=false | strict=true |
33+
/// |-------------|---------|-------------|-------------|
34+
/// | 1 | 1 | Continue | Continue |
35+
/// | None | 1 | Continue | New trace |
36+
/// | 1 | None | Continue | New trace |
37+
/// | None | None | Continue | Continue |
38+
/// | 1 | 2 | New trace | New trace |
39+
@objc public static func shouldContinueTrace(
40+
options: Options,
41+
baggageOrgId: String?
42+
) -> Bool {
43+
let sdkOrgId = options.effectiveOrgId
44+
45+
// Mismatched org IDs always reject regardless of strict mode
46+
if let sdkOrgId = sdkOrgId,
47+
let baggageOrgId = baggageOrgId,
48+
sdkOrgId != baggageOrgId {
49+
SentrySDKLog.debug(
50+
"Won't continue trace because org IDs don't match "
51+
+ "(incoming baggage: \(baggageOrgId), SDK options: \(sdkOrgId))"
52+
)
53+
return false
54+
}
55+
56+
if options.strictTraceContinuation {
57+
// With strict continuation both must be present and match,
58+
// unless both are missing
59+
if sdkOrgId == nil && baggageOrgId == nil {
60+
return true
61+
}
62+
if sdkOrgId == nil || baggageOrgId == nil {
63+
SentrySDKLog.debug(
64+
"Starting new trace because strict trace continuation is enabled "
65+
+ "but one org ID is missing (incoming baggage: "
66+
+ "\(baggageOrgId ?? "nil"), SDK: \(sdkOrgId ?? "nil"))"
67+
)
68+
return false
69+
}
70+
}
71+
72+
return true
73+
}
2774
}
2875
// swiftlint:enable missing_docs

0 commit comments

Comments
 (0)