Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- Add `attachAllThreads` option to `SentryOptions` to attach full stack traces for all threads to captured events (#7764)
- Add per-call `attachAllThreads` parameter to `capture(event:)`, `capture(error:)`, `capture(exception:)`, and `capture(message:)` to override the global option for specific calls (#7767)
- Prevent cross-organization trace continuation (#7705)
- 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.

### Improvements

Expand Down
16 changes: 16 additions & 0 deletions Sources/Sentry/Public/SentryBaggage.h
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ NS_SWIFT_NAME(Baggage)

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

/**
* The organization ID extracted from the DSN or configured explicitly.
*/
@property (nullable, nonatomic, readonly) NSString *orgId;

- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
Expand All @@ -78,6 +83,17 @@ NS_SWIFT_NAME(Baggage)
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId;

- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
environment:(nullable NSString *)environment
transaction:(nullable NSString *)transaction
sampleRate:(nullable NSString *)sampleRate
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
orgId:(nullable NSString *)orgId;

- (NSString *)toHTTPHeaderWithOriginalBaggage:(NSDictionary *_Nullable)originalBaggage;

@end
Expand Down
5 changes: 5 additions & 0 deletions Sources/Sentry/Public/SentryTraceContext.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ NS_SWIFT_NAME(TraceContext)
*/
@property (nullable, nonatomic, readonly) NSString *replayId;

/**
* The organization ID extracted from the DSN or configured explicitly.
*/
@property (nullable, nonatomic, readonly) NSString *orgId;

/**
* Create a SentryBaggage with the information of this SentryTraceContext.
*/
Expand Down
31 changes: 30 additions & 1 deletion Sources/Sentry/SentryBaggage.m
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampleRate:sampleRate
sampleRand:nil
sampled:sampled
replayId:replayId];
replayId:replayId
orgId:nil];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
Expand All @@ -38,6 +39,29 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
{
return [self initWithTraceId:traceId
publicKey:publicKey
releaseName:releaseName
environment:environment
transaction:transaction
sampleRate:sampleRate
sampleRand:sampleRand
sampled:sampled
replayId:replayId
orgId:nil];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
environment:(nullable NSString *)environment
transaction:(nullable NSString *)transaction
sampleRate:(nullable NSString *)sampleRate
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
orgId:(nullable NSString *)orgId
{

if (self = [super init]) {
_traceId = traceId;
Expand All @@ -49,6 +73,7 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
_sampleRand = sampleRand;
_sampled = sampled;
_replayId = replayId;
_orgId = orgId;
}

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

if (_orgId != nil) {
[information setValue:_orgId forKey:@"sentry-org_id"];
}

return [SentryBaggageSerialization encodeDictionary:information];
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/Sentry/SentryOptionsInternal.m
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,13 @@ + (BOOL)validateOptions:(NSDictionary<NSString *, id> *)options
block:^(BOOL value) { sentryOptions.enableMetricKitRawPayload = value; }];
#endif // SENTRY_HAS_METRIC_KIT

[self setBool:options[@"strictTraceContinuation"]
block:^(BOOL value) { sentryOptions.strictTraceContinuation = value; }];

if ([options[@"orgId"] isKindOfClass:[NSString class]]) {
sentryOptions.orgId = SENTRY_UNWRAP_NULLABLE(NSString, options[@"orgId"]);
}

[self setBool:options[@"enableSpotlight"]
block:^(BOOL value) { sentryOptions.enableSpotlight = value; }];

Expand Down
43 changes: 38 additions & 5 deletions Sources/Sentry/SentryTraceContext.m
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampleRate:sampleRate
sampleRand:nil
sampled:sampled
replayId:replayId];
replayId:replayId
orgId:nil];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
Expand All @@ -45,6 +46,29 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
{
return [self initWithTraceId:traceId
publicKey:publicKey
releaseName:releaseName
environment:environment
transaction:transaction
sampleRate:sampleRate
sampleRand:sampleRand
sampled:sampled
replayId:replayId
orgId:nil];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
environment:(nullable NSString *)environment
transaction:(nullable NSString *)transaction
sampleRate:(nullable NSString *)sampleRate
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
orgId:(nullable NSString *)orgId
{
if (self = [super init]) {
_traceId = traceId;
Expand All @@ -56,6 +80,7 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
_sampleRate = sampleRate;
_sampled = sampled;
_replayId = replayId;
_orgId = orgId;
}
return self;
}
Expand Down Expand Up @@ -102,7 +127,8 @@ - (nullable instancetype)initWithTracer:(SentryTracer *)tracer
sampleRate:serializedSampleRate
sampleRand:serializedSampleRand
sampled:sampled
replayId:scope.replayId];
replayId:scope.replayId
orgId:options.effectiveOrgId];
}

- (instancetype)initWithTraceId:(SentryId *)traceId
Expand All @@ -118,7 +144,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId
sampleRate:nil
sampleRand:nil
sampled:nil
replayId:replayId];
replayId:replayId
orgId:options.effectiveOrgId];
}

- (nullable instancetype)initWithDict:(NSDictionary<NSString *, id> *)dictionary
Expand All @@ -143,7 +170,8 @@ - (nullable instancetype)initWithDict:(NSDictionary<NSString *, id> *)dictionary
sampleRate:dictionary[@"sample_rate"]
sampleRand:dictionary[@"sample_rand"]
sampled:dictionary[@"sampled"]
replayId:dictionary[@"replay_id"]];
replayId:dictionary[@"replay_id"]
orgId:dictionary[@"org_id"]];
}

- (SentryBaggage *)toBaggage
Expand All @@ -156,7 +184,8 @@ - (SentryBaggage *)toBaggage
sampleRate:_sampleRate
sampleRand:_sampleRand
sampled:_sampled
replayId:_replayId];
replayId:_replayId
orgId:_orgId];
return result;
}

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

if (_orgId != nil) {
[result setValue:_orgId forKey:@"org_id"];
}

return result;
}

Expand Down
14 changes: 14 additions & 0 deletions Sources/Sentry/include/SentryTraceContext+Private.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ NS_ASSUME_NONNULL_BEGIN
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId;

/**
* Initializes a SentryTraceContext with given properties including org ID.
*/
- (instancetype)initWithTraceId:(SentryId *)traceId
publicKey:(NSString *)publicKey
releaseName:(nullable NSString *)releaseName
environment:(nullable NSString *)environment
transaction:(nullable NSString *)transaction
sampleRate:(nullable NSString *)sampleRate
sampleRand:(nullable NSString *)sampleRand
sampled:(nullable NSString *)sampled
replayId:(nullable NSString *)replayId
orgId:(nullable NSString *)orgId;

/**
* Initializes a SentryTraceContext with data from scope and options.
*/
Expand Down
29 changes: 29 additions & 0 deletions Sources/Swift/Options.swift
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,35 @@
/// https://spotlightjs.com/
@objc public var spotlightUrl = "http://localhost:8969/stream"

/// If set to `true`, the SDK will only continue a trace if the organization ID of the incoming
/// trace found in the baggage header matches the organization ID of the current Sentry client.
///
/// The client's organization ID is extracted from the DSN or can be set with the `orgId` option.
///
/// If the organization IDs do not match, the SDK will start a new trace instead of continuing
/// the incoming one. This is useful to prevent traces of unknown third-party services from being
/// continued in your application.
///
/// @note Default value is @c false.
@objc public var strictTraceContinuation: Bool = false

/// The organization ID for your Sentry project.
///
/// The SDK will try to extract the organization ID from the DSN. If it cannot be found, or if
/// you need to override it, you can provide the ID with this option. The organization ID is used
/// for trace propagation and for features like `strictTraceContinuation`.
@objc public var orgId: String?

/// Returns the effective organization ID, preferring the explicit `orgId` option over the
/// DSN-extracted value.
@_spi(Private) @objc
public var effectiveOrgId: String? {
if let orgId = orgId, !orgId.isEmpty {
return orgId
}
return parsedDsn?.orgId
}

/// Options for experimental features that are subject to change.
@objc public var experimental = SentryExperimentalOptions()

Expand Down
17 changes: 17 additions & 0 deletions Sources/Swift/SentryDsn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ public final class SentryDsn: NSObject {
return endpoint
}

private static let orgIdRegex = try? NSRegularExpression(pattern: "^o(\\d+)\\.")

/// Extracts the organization ID from the DSN host.
///
/// For example, given a DSN with host `o123.ingest.sentry.io`, this returns `"123"`.
/// Returns `nil` if the host does not match the expected pattern.
@_spi(Private) @objc
public var orgId: String? {
guard let host = url.host,
let regex = SentryDsn.orgIdRegex,
let match = regex.firstMatch(in: host, range: NSRange(host.startIndex..., in: host)),
let range = Range(match.range(at: 1), in: host) else {
return nil
}
return String(host[range])
}

/// Returns the base API endpoint URL for this DSN.
/// - Returns: The base endpoint URL.
private func getBaseEndpoint() -> URL {
Expand Down
51 changes: 49 additions & 2 deletions Sources/Swift/State/SentryPropagationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
@objc public var traceHeader: TraceHeader {
TraceHeader(trace: traceId, spanId: spanId, sampled: .no)
}

@objc public override init() {
self.traceId = SentryId()
self.spanId = SpanId()
Expand All @@ -17,12 +17,59 @@
self.traceId = traceId
self.spanId = spanId
}

@objc public func traceContextForEvent() -> [String: String] {
[
"span_id": spanId.sentrySpanIdString,
"trace_id": traceId.sentryIdString
]
}

/// Determines whether a trace should be continued based on the incoming baggage org ID
/// and the SDK options.
///
/// Decision matrix:
/// | Baggage org | SDK org | strict=false | strict=true |
/// |-------------|---------|-------------|-------------|
/// | 1 | 1 | Continue | Continue |
/// | None | 1 | Continue | New trace |
/// | 1 | None | Continue | New trace |
/// | None | None | Continue | Continue |
/// | 1 | 2 | New trace | New trace |
@objc public static func shouldContinueTrace(
options: Options,
baggageOrgId: String?
) -> Bool {
let sdkOrgId = options.effectiveOrgId

// Mismatched org IDs always reject regardless of strict mode
if let sdkOrgId = sdkOrgId,
let baggageOrgId = baggageOrgId,
sdkOrgId != baggageOrgId {
SentrySDKLog.debug(
"Won't continue trace because org IDs don't match "
+ "(incoming baggage: \(baggageOrgId), SDK options: \(sdkOrgId))"
)
return false
}

if options.strictTraceContinuation {
// With strict continuation both must be present and match,
// unless both are missing
if sdkOrgId == nil && baggageOrgId == nil {
return true
}
if sdkOrgId == nil || baggageOrgId == nil {
SentrySDKLog.debug(
"Starting new trace because strict trace continuation is enabled "
+ "but one org ID is missing (incoming baggage: "
+ "\(baggageOrgId ?? "nil"), SDK: \(sdkOrgId ?? "nil"))"
)
return false
}
}

return true
}
}
// swiftlint:enable missing_docs
Loading
Loading