diff --git a/CHANGELOG.md b/CHANGELOG.md index 19afbe7d9f9..46db2e0ea3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### 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. + ## 9.9.0 ### Features 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 25371c66f2b..d40ab865130 100644 --- a/Sources/Swift/Options.swift +++ b/Sources/Swift/Options.swift @@ -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() diff --git a/Sources/Swift/SentryDsn.swift b/Sources/Swift/SentryDsn.swift index d12ee3b3a36..adbb4acf300 100644 --- a/Sources/Swift/SentryDsn.swift +++ b/Sources/Swift/SentryDsn.swift @@ -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 { diff --git a/Sources/Swift/State/SentryPropagationContext.swift b/Sources/Swift/State/SentryPropagationContext.swift index 92bfc0703ab..1e895b4fa8c 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,66 @@ 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. + /// + /// 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 | + /// |-------------|---------|-------------|-------------| + /// | 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/OptionsInSyncWithDocs/SentryOptionsDocumentationSyncTests.swift b/Tests/SentryTests/OptionsInSyncWithDocs/SentryOptionsDocumentationSyncTests.swift index d148eb6e694..4a84863f1f9 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 diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index 9ed471086be..066211bd6e4 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 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) diff --git a/sdk_api.json b/sdk_api.json index c0dd686b261..edabb3d7d0b 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": [ { @@ -25036,6 +25246,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": [ { @@ -25943,6 +26254,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": [ { @@ -68607,6 +68994,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": [ {