From bdc33d95c0108c75d9740ac025727bf7d72fe6bf Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 17 Mar 2026 14:19:56 +0100 Subject: [PATCH 1/8] feat: Implement strict trace continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 4 + Sources/Sentry/Public/SentryBaggage.h | 16 ++ Sources/Sentry/Public/SentryTraceContext.h | 5 + Sources/Sentry/SentryBaggage.m | 31 ++- Sources/Sentry/SentryOptionsInternal.m | 7 + Sources/Sentry/SentryTraceContext.m | 43 ++++- .../include/SentryTraceContext+Private.h | 14 ++ Sources/Swift/Options.swift | 29 +++ Sources/Swift/SentryDsn.swift | 16 ++ .../State/SentryPropagationContext.swift | 51 ++++- .../Networking/SentryDsnOrgIdTests.swift | 61 ++++++ .../Transaction/SentryBaggageTests.swift | 48 +++++ .../SentryStrictTraceContinuationTests.swift | 176 ++++++++++++++++++ .../Transaction/SentryTraceContextTests.swift | 144 +++++++++++++- 14 files changed, 634 insertions(+), 11 deletions(-) create mode 100644 Tests/SentryTests/Networking/SentryDsnOrgIdTests.swift create mode 100644 Tests/SentryTests/Transaction/SentryStrictTraceContinuationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d968b48e7b..ee1195b2982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Features +- 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. - Add `SentrySDK.lastRunStatus` to distinguish unknown, no-crash and crash (#7469) ## 9.7.0 diff --git a/Sources/Sentry/Public/SentryBaggage.h b/Sources/Sentry/Public/SentryBaggage.h index aa1cdcce885..23f97da8750 100644 --- a/Sources/Sentry/Public/SentryBaggage.h +++ b/Sources/Sentry/Public/SentryBaggage.h @@ -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 @@ -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 diff --git a/Sources/Sentry/Public/SentryTraceContext.h b/Sources/Sentry/Public/SentryTraceContext.h index 3a04e3d0190..73dcab03e41 100644 --- a/Sources/Sentry/Public/SentryTraceContext.h +++ b/Sources/Sentry/Public/SentryTraceContext.h @@ -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. */ diff --git a/Sources/Sentry/SentryBaggage.m b/Sources/Sentry/SentryBaggage.m index 98065af5156..e0924ee3754 100644 --- a/Sources/Sentry/SentryBaggage.m +++ b/Sources/Sentry/SentryBaggage.m @@ -25,7 +25,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId sampleRate:sampleRate sampleRand:nil sampled:sampled - replayId:replayId]; + replayId:replayId + orgId:nil]; } - (instancetype)initWithTraceId:(SentryId *)traceId @@ -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; @@ -49,6 +73,7 @@ - (instancetype)initWithTraceId:(SentryId *)traceId _sampleRand = sampleRand; _sampled = sampled; _replayId = replayId; + _orgId = orgId; } return self; @@ -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]; } diff --git a/Sources/Sentry/SentryOptionsInternal.m b/Sources/Sentry/SentryOptionsInternal.m index 9ddcda3bb9e..3666d1f5566 100644 --- a/Sources/Sentry/SentryOptionsInternal.m +++ b/Sources/Sentry/SentryOptionsInternal.m @@ -304,6 +304,13 @@ + (BOOL)validateOptions:(NSDictionary *)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; }]; diff --git a/Sources/Sentry/SentryTraceContext.m b/Sources/Sentry/SentryTraceContext.m index 51159ba1917..c49d2059c1e 100644 --- a/Sources/Sentry/SentryTraceContext.m +++ b/Sources/Sentry/SentryTraceContext.m @@ -33,7 +33,8 @@ - (instancetype)initWithTraceId:(SentryId *)traceId sampleRate:sampleRate sampleRand:nil sampled:sampled - replayId:replayId]; + replayId:replayId + orgId:nil]; } - (instancetype)initWithTraceId:(SentryId *)traceId @@ -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; @@ -56,6 +80,7 @@ - (instancetype)initWithTraceId:(SentryId *)traceId _sampleRate = sampleRate; _sampled = sampled; _replayId = replayId; + _orgId = orgId; } return self; } @@ -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 @@ -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 *)dictionary @@ -143,7 +170,8 @@ - (nullable instancetype)initWithDict:(NSDictionary *)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 @@ -156,7 +184,8 @@ - (SentryBaggage *)toBaggage sampleRate:_sampleRate sampleRand:_sampleRand sampled:_sampled - replayId:_replayId]; + replayId:_replayId + orgId:_orgId]; return result; } @@ -193,6 +222,10 @@ - (SentryBaggage *)toBaggage [result setValue:_replayId forKey:@"replay_id"]; } + if (_orgId != nil) { + [result setValue:_orgId forKey:@"org_id"]; + } + return result; } diff --git a/Sources/Sentry/include/SentryTraceContext+Private.h b/Sources/Sentry/include/SentryTraceContext+Private.h index 0191dc3a1e0..e5bfb4711c8 100644 --- a/Sources/Sentry/include/SentryTraceContext+Private.h +++ b/Sources/Sentry/include/SentryTraceContext+Private.h @@ -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. */ diff --git a/Sources/Swift/Options.swift b/Sources/Swift/Options.swift index 87abc860c77..362579cd81e 100644 --- a/Sources/Swift/Options.swift +++ b/Sources/Swift/Options.swift @@ -632,6 +632,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() diff --git a/Sources/Swift/SentryDsn.swift b/Sources/Swift/SentryDsn.swift index d12ee3b3a36..ce8a871e4f0 100644 --- a/Sources/Swift/SentryDsn.swift +++ b/Sources/Swift/SentryDsn.swift @@ -106,6 +106,22 @@ public final class SentryDsn: NSObject { return endpoint } + /// 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 else { return nil } + let pattern = "^o(\\d+)\\." + guard let regex = try? NSRegularExpression(pattern: pattern), + 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 { diff --git a/Sources/Swift/State/SentryPropagationContext.swift b/Sources/Swift/State/SentryPropagationContext.swift index 92bfc0703ab..5912374b840 100644 --- a/Sources/Swift/State/SentryPropagationContext.swift +++ b/Sources/Swift/State/SentryPropagationContext.swift @@ -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() @@ -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 diff --git a/Tests/SentryTests/Networking/SentryDsnOrgIdTests.swift b/Tests/SentryTests/Networking/SentryDsnOrgIdTests.swift new file mode 100644 index 00000000000..44570bf3bf9 --- /dev/null +++ b/Tests/SentryTests/Networking/SentryDsnOrgIdTests.swift @@ -0,0 +1,61 @@ +@_spi(Private) import Sentry +import XCTest + +final class SentryDsnOrgIdTests: XCTestCase { + + func testOrgId_whenHostHasOrgPrefix_shouldExtractOrgId() throws { + // -- Arrange -- + let dsn = try SentryDsn(string: "https://key@o123.ingest.sentry.io/456") + + // -- Assert -- + XCTAssertEqual(dsn.orgId, "123") + } + + func testOrgId_whenHostHasSingleDigitOrgPrefix_shouldExtractOrgId() throws { + // -- Arrange -- + let dsn = try SentryDsn(string: "https://key@o1.ingest.us.sentry.io/456") + + // -- Assert -- + XCTAssertEqual(dsn.orgId, "1") + } + + func testOrgId_whenHostHasLargeOrgId_shouldExtractOrgId() throws { + // -- Arrange -- + let dsn = try SentryDsn(string: "https://key@o447951.ingest.sentry.io/5428557") + + // -- Assert -- + XCTAssertEqual(dsn.orgId, "447951") + } + + func testOrgId_whenHostHasNoOrgPrefix_shouldReturnNil() throws { + // -- Arrange -- + let dsn = try SentryDsn(string: "https://key@sentry.io/456") + + // -- Assert -- + XCTAssertNil(dsn.orgId) + } + + func testOrgId_whenHostIsLocalhost_shouldReturnNil() throws { + // -- Arrange -- + let dsn = try SentryDsn(string: "http://key@localhost:9000/456") + + // -- Assert -- + XCTAssertNil(dsn.orgId) + } + + func testOrgId_whenHostHasNonNumericOrgPrefix_shouldReturnNil() throws { + // -- Arrange -- + let dsn = try SentryDsn(string: "https://key@oabc.ingest.sentry.io/456") + + // -- Assert -- + XCTAssertNil(dsn.orgId) + } + + func testOrgId_whenHostIsCustomDomain_shouldReturnNil() throws { + // -- Arrange -- + let dsn = try SentryDsn(string: "https://key@app.getsentry.com/12345") + + // -- Assert -- + XCTAssertNil(dsn.orgId) + } +} diff --git a/Tests/SentryTests/Transaction/SentryBaggageTests.swift b/Tests/SentryTests/Transaction/SentryBaggageTests.swift index d3823681370..e092d395f6d 100644 --- a/Tests/SentryTests/Transaction/SentryBaggageTests.swift +++ b/Tests/SentryTests/Transaction/SentryBaggageTests.swift @@ -155,4 +155,52 @@ class SentryBaggageTests: XCTestCase { // -- Assert -- XCTAssertEqual(header, "sentry-public_key=publicKey,sentry-replay_id=replay-id,sentry-trace_id=00000000000000000000000000000000") } + + // MARK: - orgId tests + + func testToHTTPHeader_withOrgId_shouldIncludeOrgId() { + // -- Arrange -- + let baggage = Baggage( + trace: SentryId.empty, publicKey: "publicKey", releaseName: nil, environment: nil, + transaction: nil, + sampleRate: nil, sampleRand: nil, sampled: nil, replayId: nil, orgId: "123" + ) + + // -- Act -- + let header = baggage.toHTTPHeader(withOriginalBaggage: nil) + + // -- Assert -- + XCTAssertTrue(header.contains("sentry-org_id=123")) + } + + func testToHTTPHeader_withNilOrgId_shouldNotIncludeOrgId() { + // -- Arrange -- + let baggage = Baggage( + trace: SentryId.empty, publicKey: "publicKey", releaseName: nil, environment: nil, + transaction: nil, + sampleRate: nil, sampleRand: nil, sampled: nil, replayId: nil, orgId: nil + ) + + // -- Act -- + let header = baggage.toHTTPHeader(withOriginalBaggage: nil) + + // -- Assert -- + XCTAssertFalse(header.contains("sentry-org_id")) + } + + func testToHTTPHeader_orgIdInOriginalBaggage_shouldBeOverwritten() { + // -- Arrange -- + let baggage = Baggage( + trace: SentryId.empty, publicKey: "publicKey", releaseName: nil, environment: nil, + transaction: nil, + sampleRate: nil, sampleRand: nil, sampled: nil, replayId: nil, orgId: "456" + ) + + // -- Act -- + let header = baggage.toHTTPHeader(withOriginalBaggage: ["sentry-org_id": "123"]) + + // -- Assert -- + XCTAssertTrue(header.contains("sentry-org_id=456")) + XCTAssertFalse(header.contains("sentry-org_id=123")) + } } diff --git a/Tests/SentryTests/Transaction/SentryStrictTraceContinuationTests.swift b/Tests/SentryTests/Transaction/SentryStrictTraceContinuationTests.swift new file mode 100644 index 00000000000..73a28152d8b --- /dev/null +++ b/Tests/SentryTests/Transaction/SentryStrictTraceContinuationTests.swift @@ -0,0 +1,176 @@ +@_spi(Private) import Sentry +import SentryTestUtils +import XCTest + +final class SentryStrictTraceContinuationTests: XCTestCase { + + // MARK: - Options defaults + + func testOptions_strictTraceContinuation_defaultsFalse() { + let options = Options() + XCTAssertFalse(options.strictTraceContinuation) + } + + func testOptions_orgId_defaultsNil() { + let options = Options() + XCTAssertNil(options.orgId) + } + + // MARK: - effectiveOrgId + + func testEffectiveOrgId_whenExplicitOrgIdSet_shouldPreferExplicit() { + // -- Arrange -- + let options = Options() + options.dsn = "https://key@o123.ingest.sentry.io/456" + options.orgId = "999" + + // -- Assert -- + XCTAssertEqual(options.effectiveOrgId, "999") + } + + func testEffectiveOrgId_whenNoExplicitOrgId_shouldFallBackToDsn() { + // -- Arrange -- + let options = Options() + options.dsn = "https://key@o123.ingest.sentry.io/456" + + // -- Assert -- + XCTAssertEqual(options.effectiveOrgId, "123") + } + + func testEffectiveOrgId_whenNoOrgIdConfigured_shouldReturnNil() { + // -- Arrange -- + let options = Options() + options.dsn = "https://key@sentry.io/456" + + // -- Assert -- + XCTAssertNil(options.effectiveOrgId) + } + + func testEffectiveOrgId_whenEmptyExplicitOrgId_shouldFallBackToDsn() { + // -- Arrange -- + let options = Options() + options.dsn = "https://key@o123.ingest.sentry.io/456" + options.orgId = "" + + // -- Assert -- + XCTAssertEqual(options.effectiveOrgId, "123") + } + + // MARK: - shouldContinueTrace - strict=false + + func testShouldContinueTrace_whenStrictFalse_matchingOrgs_shouldContinue() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: "1", strict: false) + + // -- Act & Assert -- + XCTAssertTrue( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: "1") + ) + } + + func testShouldContinueTrace_whenStrictFalse_baggageMissingOrg_shouldContinue() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: "1", strict: false) + + // -- Act & Assert -- + XCTAssertTrue( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: nil) + ) + } + + func testShouldContinueTrace_whenStrictFalse_sdkMissingOrg_shouldContinue() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: nil, strict: false) + + // -- Act & Assert -- + XCTAssertTrue( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: "1") + ) + } + + func testShouldContinueTrace_whenStrictFalse_bothMissingOrg_shouldContinue() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: nil, strict: false) + + // -- Act & Assert -- + XCTAssertTrue( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: nil) + ) + } + + func testShouldContinueTrace_whenStrictFalse_mismatchedOrgs_shouldStartNewTrace() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: "2", strict: false) + + // -- Act & Assert -- + XCTAssertFalse( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: "1") + ) + } + + // MARK: - shouldContinueTrace - strict=true + + func testShouldContinueTrace_whenStrictTrue_matchingOrgs_shouldContinue() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: "1", strict: true) + + // -- Act & Assert -- + XCTAssertTrue( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: "1") + ) + } + + func testShouldContinueTrace_whenStrictTrue_baggageMissingOrg_shouldStartNewTrace() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: "1", strict: true) + + // -- Act & Assert -- + XCTAssertFalse( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: nil) + ) + } + + func testShouldContinueTrace_whenStrictTrue_sdkMissingOrg_shouldStartNewTrace() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: nil, strict: true) + + // -- Act & Assert -- + XCTAssertFalse( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: "1") + ) + } + + func testShouldContinueTrace_whenStrictTrue_bothMissingOrg_shouldContinue() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: nil, strict: true) + + // -- Act & Assert -- + XCTAssertTrue( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: nil) + ) + } + + func testShouldContinueTrace_whenStrictTrue_mismatchedOrgs_shouldStartNewTrace() { + // -- Arrange -- + let options = makeOptions(dsnOrgId: "2", strict: true) + + // -- Act & Assert -- + XCTAssertFalse( + SentryPropagationContext.shouldContinueTrace(options: options, baggageOrgId: "1") + ) + } + + // MARK: - Helpers + + private func makeOptions(dsnOrgId: String?, explicitOrgId: String? = nil, strict: Bool) -> Options { + let options = Options() + if let dsnOrgId = dsnOrgId { + options.dsn = "https://key@o\(dsnOrgId).ingest.sentry.io/123" + } else { + options.dsn = "https://key@sentry.io/123" + } + options.orgId = explicitOrgId + options.strictTraceContinuation = strict + return options + } +} diff --git a/Tests/SentryTests/Transaction/SentryTraceContextTests.swift b/Tests/SentryTests/Transaction/SentryTraceContextTests.swift index c6ef083bfc0..85ea4d3d198 100644 --- a/Tests/SentryTests/Transaction/SentryTraceContextTests.swift +++ b/Tests/SentryTests/Transaction/SentryTraceContextTests.swift @@ -248,15 +248,15 @@ class SentryTraceContextTests: XCTestCase { sampled: nil, replayId: nil ) - + // Act let serialized = traceContext.serialize() - + // Assert // Required fields must be present XCTAssertEqual(serialized["trace_id"] as? String, fixture.traceId.sentryIdString) XCTAssertEqual(serialized["public_key"] as? String, fixture.publicKey) - + // Optional fields should be absent XCTAssertNil(serialized["release"]) XCTAssertNil(serialized["environment"]) @@ -266,6 +266,144 @@ class SentryTraceContextTests: XCTestCase { XCTAssertNil(serialized["sampled"]) XCTAssertNil(serialized["replay_id"]) } + + // MARK: - orgId tests + + func testInitWithTracer_whenDsnHasOrgId_shouldIncludeOrgId() { + // -- Arrange -- + let options = Options() + options.dsn = "https://key@o123.ingest.sentry.io/456" + options.releaseName = fixture.releaseName + options.environment = fixture.environment + + // -- Act -- + let traceContext = TraceContext(tracer: fixture.tracer, scope: fixture.scope, options: options) + + // -- Assert -- + XCTAssertEqual(traceContext?.orgId, "123") + } + + func testInitWithTracer_whenExplicitOrgIdSet_shouldUseExplicitOrgId() { + // -- Arrange -- + let options = Options() + options.dsn = "https://key@o123.ingest.sentry.io/456" + options.orgId = "999" + options.releaseName = fixture.releaseName + options.environment = fixture.environment + + // -- Act -- + let traceContext = TraceContext(tracer: fixture.tracer, scope: fixture.scope, options: options) + + // -- Assert -- + XCTAssertEqual(traceContext?.orgId, "999") + } + + func testInitWithTraceIdOptions_shouldIncludeOrgId() { + // -- Arrange -- + let options = Options() + options.dsn = "https://key@o123.ingest.sentry.io/456" + + // -- Act -- + let traceContext = TraceContext(trace: SentryId(), options: options, replayId: nil) + + // -- Assert -- + XCTAssertEqual(traceContext.orgId, "123") + } + + func testInitWithDict_whenOrgIdPresent_shouldParseOrgId() { + // -- Arrange -- + let dict: [String: Any] = [ + "trace_id": SentryId().sentryIdString, + "public_key": "test", + "org_id": "456" + ] + + // -- Act -- + let traceContext = TraceContext(dict: dict) + + // -- Assert -- + XCTAssertEqual(traceContext?.orgId, "456") + } + + func testInitWithDict_whenOrgIdAbsent_shouldReturnNilOrgId() { + // -- Arrange -- + let dict: [String: Any] = [ + "trace_id": SentryId().sentryIdString, + "public_key": "test" + ] + + // -- Act -- + let traceContext = TraceContext(dict: dict) + + // -- Assert -- + XCTAssertNil(traceContext?.orgId) + } + + func testSerialize_whenOrgIdSet_shouldIncludeOrgId() { + // -- Arrange -- + let traceContext = TraceContext( + trace: fixture.traceId, + publicKey: fixture.publicKey, + releaseName: nil, + environment: nil, + transaction: nil, + sampleRate: nil, + sampleRand: nil, + sampled: nil, + replayId: nil, + orgId: "789" + ) + + // -- Act -- + let serialized = traceContext.serialize() + + // -- Assert -- + XCTAssertEqual(serialized["org_id"] as? String, "789") + } + + func testSerialize_whenOrgIdNil_shouldNotIncludeOrgId() { + // -- Arrange -- + let traceContext = TraceContext( + trace: fixture.traceId, + publicKey: fixture.publicKey, + releaseName: nil, + environment: nil, + transaction: nil, + sampleRate: nil, + sampleRand: nil, + sampled: nil, + replayId: nil, + orgId: nil + ) + + // -- Act -- + let serialized = traceContext.serialize() + + // -- Assert -- + XCTAssertNil(serialized["org_id"]) + } + + func testToBaggage_shouldIncludeOrgId() { + // -- Arrange -- + let traceContext = TraceContext( + trace: fixture.traceId, + publicKey: fixture.publicKey, + releaseName: nil, + environment: nil, + transaction: nil, + sampleRate: nil, + sampleRand: nil, + sampled: nil, + replayId: nil, + orgId: "321" + ) + + // -- Act -- + let baggage = traceContext.toBaggage() + + // -- Assert -- + XCTAssertEqual(baggage.orgId, "321") + } private func assertTraceState(traceContext: TraceContext) { XCTAssertEqual(traceContext.traceId, fixture.traceId) From 33b3acd966235d35fad957a69008bc9cad055e90 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 17 Mar 2026 16:58:10 +0100 Subject: [PATCH 2/8] chore: Regenerate public API baseline --- sdk_api.json | 451 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 451 insertions(+) diff --git a/sdk_api.json b/sdk_api.json index 7d7b65d11f3..67a0da692e2 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -3344,6 +3344,152 @@ "printedName": "init(trace:publicKey:releaseName:environment:transaction:sampleRate:sampleRand:sampled:replayId:)", "usr": "c:objc(cs)SentryBaggage(im)initWithTraceId:publicKey:releaseName:environment:transaction:sampleRate:sampleRand:sampled:replayId:" }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "Baggage", + "printedName": "Sentry.Baggage", + "usr": "c:objc(cs)SentryBaggage" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + }, + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "SentryId", + "printedName": "Sentry.SentryId", + "usr": "c:objc(cs)SentryId" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declAttributes": [ + "Dynamic", + "ObjC" + ], + "declKind": "Constructor", + "init_kind": "Designated", + "kind": "Constructor", + "moduleName": "Sentry", + "name": "init", + "objc_name": "initWithTraceId:publicKey:releaseName:environment:transaction:sampleRate:sampleRand:sampled:replayId:orgId:", + "printedName": "init(trace:publicKey:releaseName:environment:transaction:sampleRate:sampleRand:sampled:replayId:orgId:)", + "usr": "c:objc(cs)SentryBaggage(im)initWithTraceId:publicKey:releaseName:environment:transaction:sampleRate:sampleRand:sampled:replayId:orgId:" + }, { "children": [ { @@ -3577,6 +3723,70 @@ "printedName": "environment", "usr": "c:objc(cs)SentryBaggage(py)environment" }, + { + "accessors": [ + { + "accessorKind": "get", + "children": [ + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + } + ], + "declAttributes": [ + "DiscardableResult", + "Dynamic", + "ObjC" + ], + "declKind": "Accessor", + "isOpen": true, + "kind": "Accessor", + "moduleName": "Sentry", + "name": "Get", + "objc_name": "orgId", + "printedName": "Get()", + "usr": "c:objc(cs)SentryBaggage(im)orgId" + } + ], + "children": [ + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + } + ], + "declAttributes": [ + "Dynamic", + "ObjC" + ], + "declKind": "Var", + "isOpen": true, + "kind": "Var", + "moduleName": "Sentry", + "name": "orgId", + "objc_name": "orgId", + "printedName": "orgId", + "usr": "c:objc(cs)SentryBaggage(py)orgId" + }, { "accessors": [ { @@ -24960,6 +25170,107 @@ "printedName": "onLastRunStatusDetermined", "usr": "c:@M@Sentry@objc(cs)SentryOptions(py)onLastRunStatusDetermined" }, + { + "accessors": [ + { + "accessorKind": "get", + "children": [ + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + } + ], + "declAttributes": [ + "Final", + "ObjC" + ], + "declKind": "Accessor", + "implicit": true, + "kind": "Accessor", + "mangledName": "$s6Sentry7OptionsC5orgIdSSSgvg", + "moduleName": "Sentry", + "name": "Get", + "printedName": "Get()", + "usr": "c:@M@Sentry@objc(cs)SentryOptions(im)orgId" + }, + { + "accessorKind": "set", + "children": [ + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ], + "declAttributes": [ + "Final", + "ObjC" + ], + "declKind": "Accessor", + "implicit": true, + "kind": "Accessor", + "mangledName": "$s6Sentry7OptionsC5orgIdSSSgvs", + "moduleName": "Sentry", + "name": "Set", + "printedName": "Set()", + "usr": "c:@M@Sentry@objc(cs)SentryOptions(im)setOrgId:" + } + ], + "children": [ + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + } + ], + "declAttributes": [ + "Final", + "HasInitialValue", + "HasStorage", + "ObjC" + ], + "declKind": "Var", + "hasStorage": true, + "kind": "Var", + "mangledName": "$s6Sentry7OptionsC5orgIdSSSgvp", + "moduleName": "Sentry", + "name": "orgId", + "printedName": "orgId", + "usr": "c:@M@Sentry@objc(cs)SentryOptions(py)orgId" + }, { "accessors": [ { @@ -25867,6 +26178,82 @@ "printedName": "spotlightUrl", "usr": "c:@M@Sentry@objc(cs)SentryOptions(py)spotlightUrl" }, + { + "accessors": [ + { + "accessorKind": "get", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declAttributes": [ + "Final", + "ObjC" + ], + "declKind": "Accessor", + "implicit": true, + "kind": "Accessor", + "mangledName": "$s6Sentry7OptionsC23strictTraceContinuationSbvg", + "moduleName": "Sentry", + "name": "Get", + "printedName": "Get()", + "usr": "c:@M@Sentry@objc(cs)SentryOptions(im)strictTraceContinuation" + }, + { + "accessorKind": "set", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + }, + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ], + "declAttributes": [ + "Final", + "ObjC" + ], + "declKind": "Accessor", + "implicit": true, + "kind": "Accessor", + "mangledName": "$s6Sentry7OptionsC23strictTraceContinuationSbvs", + "moduleName": "Sentry", + "name": "Set", + "printedName": "Set()", + "usr": "c:@M@Sentry@objc(cs)SentryOptions(im)setStrictTraceContinuation:" + } + ], + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declAttributes": [ + "Final", + "HasStorage", + "ObjC" + ], + "declKind": "Var", + "hasStorage": true, + "kind": "Var", + "mangledName": "$s6Sentry7OptionsC23strictTraceContinuationSbvp", + "moduleName": "Sentry", + "name": "strictTraceContinuation", + "printedName": "strictTraceContinuation", + "usr": "c:@M@Sentry@objc(cs)SentryOptions(py)strictTraceContinuation" + }, { "accessors": [ { @@ -68383,6 +68770,70 @@ "printedName": "environment", "usr": "c:objc(cs)SentryTraceContext(py)environment" }, + { + "accessors": [ + { + "accessorKind": "get", + "children": [ + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + } + ], + "declAttributes": [ + "DiscardableResult", + "Dynamic", + "ObjC" + ], + "declKind": "Accessor", + "isOpen": true, + "kind": "Accessor", + "moduleName": "Sentry", + "name": "Get", + "objc_name": "orgId", + "printedName": "Get()", + "usr": "c:objc(cs)SentryTraceContext(im)orgId" + } + ], + "children": [ + { + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "usr": "s:Sq" + } + ], + "declAttributes": [ + "Dynamic", + "ObjC" + ], + "declKind": "Var", + "isOpen": true, + "kind": "Var", + "moduleName": "Sentry", + "name": "orgId", + "objc_name": "orgId", + "printedName": "orgId", + "usr": "c:objc(cs)SentryTraceContext(py)orgId" + }, { "accessors": [ { From bc60731b9a5784ddb6eaf07db793e4e4f774b1cc Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 26 Mar 2026 16:05:23 +0100 Subject: [PATCH 3/8] chore: Move changelog entry to Unreleased section --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d832a6de0..5a612d4432b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 9.8.0 +## Unreleased ### Features @@ -8,6 +8,11 @@ - 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. + +## 9.8.0 + +### Features + - Add `SentrySDK.lastRunStatus` to distinguish unknown, no-crash and crash (#7469) ### Fixes From 3e4f2aa5ecbc6f14b9937ec0a482deb0126d383f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 26 Mar 2026 16:23:11 +0100 Subject: [PATCH 4/8] perf: Cache orgId regex as static property --- Sources/Swift/SentryDsn.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Swift/SentryDsn.swift b/Sources/Swift/SentryDsn.swift index ce8a871e4f0..adbb4acf300 100644 --- a/Sources/Swift/SentryDsn.swift +++ b/Sources/Swift/SentryDsn.swift @@ -106,15 +106,16 @@ 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 else { return nil } - let pattern = "^o(\\d+)\\." - guard let regex = try? NSRegularExpression(pattern: pattern), + 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 From 3c083727fb36dbe03b8ead7fe8d0c034c3e2e41a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 26 Mar 2026 16:28:57 +0100 Subject: [PATCH 5/8] fix: Add new options to undocumented list in docs sync test --- .../SentryOptionsDocumentationSyncTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/SentryTests/OptionsInSyncWithDocs/SentryOptionsDocumentationSyncTests.swift b/Tests/SentryTests/OptionsInSyncWithDocs/SentryOptionsDocumentationSyncTests.swift index 4d4b6962d62..c823d206d8f 100644 --- a/Tests/SentryTests/OptionsInSyncWithDocs/SentryOptionsDocumentationSyncTests.swift +++ b/Tests/SentryTests/OptionsInSyncWithDocs/SentryOptionsDocumentationSyncTests.swift @@ -17,7 +17,10 @@ final class SentryOptionsDocumentationSyncTests: XCTestCase { var options: Set = [ "parsedDsn", "experimental", - "onLastRunStatusDetermined" + "onLastRunStatusDetermined", + "strictTraceContinuation", // Docs PR: https://github.com/getsentry/sentry-docs/pull/16983 + "orgId", // Docs PR: https://github.com/getsentry/sentry-docs/pull/16983 + "effectiveOrgId" // @_spi(Private) - internal computed property, not a user-facing option ] #if (os(iOS) || os(tvOS) || os(visionOS)) && !SENTRY_NO_UI_FRAMEWORK From 651a3b3b7d490ea129abb3aa100bcdaab1ddf3ac Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 8 Apr 2026 10:44:47 +0200 Subject: [PATCH 6/8] fix: Move changelog to Unreleased and add SentryOptionsTest tests --- CHANGELOG.md | 11 ++++-- Tests/SentryTests/SentryOptionsTest.m | 53 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c88a1383fd..46db2e0ea3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,21 @@ # Changelog -## 9.9.0 +## Unreleased ### Features -- 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. +## 9.9.0 + +### Features + +- 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) + ### Improvements - Align app lifecycle breadcrumb `state` values with `in_foreground`/`is_active` app context (#7703) diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index 9ed471086be..e58709fed05 100644 --- a/Tests/SentryTests/SentryOptionsTest.m +++ b/Tests/SentryTests/SentryOptionsTest.m @@ -1260,6 +1260,59 @@ - (void)testIsAppHangTrackingDisabled_WhenOnlyAppHangTimeoutIntervalZero } #endif // SENTRY_HAS_UIKIT +#pragma mark - Strict Trace Continuation + +- (void)testStrictTraceContinuation +{ + [self testBooleanField:@"strictTraceContinuation" defaultValue:NO]; +} + +- (void)testOrgId_Default +{ + SentryOptions *options = [self getValidOptions:@{}]; + XCTAssertNil(options.orgId); +} + +- (void)testOrgId_SetViaDict +{ + SentryOptions *options = [self getValidOptions:@{ @"orgId" : @"12345" }]; + XCTAssertEqualObjects(options.orgId, @"12345"); +} + +- (void)testOrgId_InvalidType_Ignored +{ + SentryOptions *options = [self getValidOptions:@{ @"orgId" : @42 }]; + XCTAssertNil(options.orgId); +} + +- (void)testEffectiveOrgId_PrefersExplicitOverDsn +{ + SentryOptions *options = [self getValidOptions:@{ @"orgId" : @"999" }]; + options.dsn = @"https://key@o123.ingest.sentry.io/456"; + XCTAssertEqualObjects(options.effectiveOrgId, @"999"); +} + +- (void)testEffectiveOrgId_FallsBackToDsn +{ + SentryOptions *options = [self getValidOptions:@{}]; + options.dsn = @"https://key@o123.ingest.sentry.io/456"; + XCTAssertEqualObjects(options.effectiveOrgId, @"123"); +} + +- (void)testEffectiveOrgId_NilWhenNoDsnOrgId +{ + SentryOptions *options = [self getValidOptions:@{}]; + options.dsn = @"https://key@sentry.io/456"; + XCTAssertNil(options.effectiveOrgId); +} + +- (void)testEffectiveOrgId_EmptyExplicitFallsBackToDsn +{ + SentryOptions *options = [self getValidOptions:@{ @"orgId" : @"" }]; + options.dsn = @"https://key@o123.ingest.sentry.io/456"; + XCTAssertEqualObjects(options.effectiveOrgId, @"123"); +} + #pragma mark - Private - (void)assertArrayEquals:(NSArray *)expected actual:(NSArray *)actual From e22dc868c969d0aa39a940831d5bf0a86a5b168f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 8 Apr 2026 10:46:49 +0200 Subject: [PATCH 7/8] docs: Add usage note to shouldContinueTrace explaining intended consumers --- Sources/Swift/State/SentryPropagationContext.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Swift/State/SentryPropagationContext.swift b/Sources/Swift/State/SentryPropagationContext.swift index 5912374b840..1e895b4fa8c 100644 --- a/Sources/Swift/State/SentryPropagationContext.swift +++ b/Sources/Swift/State/SentryPropagationContext.swift @@ -28,6 +28,13 @@ /// Determines whether a trace should be continued based on the incoming baggage org ID /// and the SDK options. /// + /// This method is intentionally not called from the Cocoa SDK's own production code because + /// the Cocoa SDK is a mobile client SDK that does not receive incoming HTTP requests with + /// trace headers. It is exposed as a public utility for: + /// - Hybrid SDKs (React Native, Flutter, Capacitor) that handle inbound trace validation + /// in their JS/Dart layer and use the Cocoa SDK for options storage and outbound propagation + /// - Any consumer that needs to validate incoming traces against org ID + /// /// Decision matrix: /// | Baggage org | SDK org | strict=false | strict=true | /// |-------------|---------|-------------|-------------| From 0da8c03cf4772f5a8a0174c380ebfe1e3f328b47 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 8 Apr 2026 10:50:21 +0200 Subject: [PATCH 8/8] style: Fix clang-format empty dictionary spacing --- Tests/SentryTests/SentryOptionsTest.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index e58709fed05..066211bd6e4 100644 --- a/Tests/SentryTests/SentryOptionsTest.m +++ b/Tests/SentryTests/SentryOptionsTest.m @@ -1269,7 +1269,7 @@ - (void)testStrictTraceContinuation - (void)testOrgId_Default { - SentryOptions *options = [self getValidOptions:@{}]; + SentryOptions *options = [self getValidOptions:@{ }]; XCTAssertNil(options.orgId); } @@ -1294,14 +1294,14 @@ - (void)testEffectiveOrgId_PrefersExplicitOverDsn - (void)testEffectiveOrgId_FallsBackToDsn { - SentryOptions *options = [self getValidOptions:@{}]; + SentryOptions *options = [self getValidOptions:@{ }]; options.dsn = @"https://key@o123.ingest.sentry.io/456"; XCTAssertEqualObjects(options.effectiveOrgId, @"123"); } - (void)testEffectiveOrgId_NilWhenNoDsnOrgId { - SentryOptions *options = [self getValidOptions:@{}]; + SentryOptions *options = [self getValidOptions:@{ }]; options.dsn = @"https://key@sentry.io/456"; XCTAssertNil(options.effectiveOrgId); }