From 3e7817b65a93de9812dd668abd8f5127cbcf0e06 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 6 Oct 2025 11:04:50 +0200 Subject: [PATCH] feat: Add propagating of traceparent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the option propagateTraceparent, which is disabled by default. When enabled, it adds the W3C Trace Context HTTP headerĀ traceparentĀ on outgoing HTTP requests. This is useful when the receiving services only support OTel/W3C propagation. Fixes GH-6017 --- CHANGELOG.md | 1 + Sources/Sentry/Public/SentryOptions.h | 12 +++ Sources/Sentry/SentryNetworkTracker.m | 11 ++- Sources/Sentry/SentryOptions.m | 1 + Sources/Sentry/SentryTracePropagation.m | 50 +++++++---- Sources/Sentry/SentyOptionsInternal.m | 3 + .../Sentry/include/SentryTracePropagation.h | 1 + .../Network/SentryNetworkTrackerTests.swift | 45 ++++++++++ .../Network/SentryTracePropagationTests.swift | 77 +++++++++++++++++ Tests/SentryTests/SentryOptionsTest.m | 5 ++ sdk_api.json | 83 +++++++++++++++++++ 11 files changed, 269 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bab17f3de..e6d5931bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### Features - Add SentryDistribution as Swift Package Manager target (#6149) +- Add option `enablePropagateTraceparent` to support OTel/W3C trace propagation (#6356) ### Fixes diff --git a/Sources/Sentry/Public/SentryOptions.h b/Sources/Sentry/Public/SentryOptions.h index e1c91bea7b..dd1a4d8d4b 100644 --- a/Sources/Sentry/Public/SentryOptions.h +++ b/Sources/Sentry/Public/SentryOptions.h @@ -688,6 +688,18 @@ typedef void (^SentryProfilingConfigurationBlock)(SentryProfileOptions *_Nonnull */ @property (nonatomic, assign) BOOL enableAutoBreadcrumbTracking; +/** + * When enabled, the SDK propagates the W3C Trace Context HTTP header traceparent on outgoing HTTP + * requests. + * + * @discussion This is useful when the receiving services only support OTel/W3C propagation. The + * traceparent header is only sent when this option is @c YES and the request matches @c + * tracePropagationTargets. + * + * @note Default value is @c NO. + */ +@property (nonatomic, assign) BOOL enablePropagateTraceparent; + /** * An array of hosts or regexes that determines if outgoing HTTP requests will get * extra @c trace_id and @c baggage headers added. diff --git a/Sources/Sentry/SentryNetworkTracker.m b/Sources/Sentry/SentryNetworkTracker.m index 798ee733ac..4dc36d26d1 100644 --- a/Sources/Sentry/SentryNetworkTracker.m +++ b/Sources/Sentry/SentryNetworkTracker.m @@ -189,10 +189,12 @@ - (void)urlSessionTaskResume:(NSURLSessionTask *)sessionTask } SentryBaggage *baggage = [[[SentryTracer getTracer:span] traceContext] toBaggage]; - [SentryTracePropagation addBaggageHeader:baggage - traceHeader:[netSpan toTraceHeader] - tracePropagationTargets:SentrySDKInternal.options.tracePropagationTargets - toRequest:sessionTask]; + [SentryTracePropagation + addBaggageHeader:baggage + traceHeader:[netSpan toTraceHeader] + propagateTraceparent:SentrySDKInternal.options.enablePropagateTraceparent + tracePropagationTargets:SentrySDKInternal.options.tracePropagationTargets + toRequest:sessionTask]; SENTRY_LOG_DEBUG( @"SentryNetworkTracker automatically started HTTP span for sessionTask: %@", @@ -226,6 +228,7 @@ - (void)addTraceWithoutTransactionToTask:(NSURLSessionTask *)sessionTask [SentryTracePropagation addBaggageHeader:[traceContext toBaggage] traceHeader:[propagationContext traceHeader] + propagateTraceparent:SentrySDKInternal.options.enablePropagateTraceparent tracePropagationTargets:SentrySDKInternal.options.tracePropagationTargets toRequest:sessionTask]; } diff --git a/Sources/Sentry/SentryOptions.m b/Sources/Sentry/SentryOptions.m index 59d1b8da41..ed7d1585f5 100644 --- a/Sources/Sentry/SentryOptions.m +++ b/Sources/Sentry/SentryOptions.m @@ -114,6 +114,7 @@ - (instancetype)init self.enableAppHangTracking = YES; self.appHangTimeoutInterval = 2.0; self.enableAutoBreadcrumbTracking = YES; + self.enablePropagateTraceparent = NO; self.enableNetworkTracking = YES; self.enableFileIOTracing = YES; self.enableNetworkBreadcrumbs = YES; diff --git a/Sources/Sentry/SentryTracePropagation.m b/Sources/Sentry/SentryTracePropagation.m index 53400634e2..ef366cb23e 100644 --- a/Sources/Sentry/SentryTracePropagation.m +++ b/Sources/Sentry/SentryTracePropagation.m @@ -4,10 +4,13 @@ #import #import +static NSString *const SENTRY_TRACEPARENT = @"traceparent"; + @implementation SentryTracePropagation + (void)addBaggageHeader:(SentryBaggage *)baggage traceHeader:(SentryTraceHeader *)traceHeader + propagateTraceparent:(BOOL)propagateTraceparent tracePropagationTargets:(NSArray *)tracePropagationTargets toRequest:(NSURLSessionTask *)sessionTask { @@ -33,14 +36,10 @@ + (void)addBaggageHeader:(SentryBaggage *)baggage // header. if ([sessionTask.currentRequest isKindOfClass:[NSMutableURLRequest class]]) { NSMutableURLRequest *currentRequest = (NSMutableURLRequest *)sessionTask.currentRequest; - - if ([currentRequest valueForHTTPHeaderField:SENTRY_TRACE_HEADER] == nil) { - [currentRequest setValue:traceHeader.value forHTTPHeaderField:SENTRY_TRACE_HEADER]; - } - - if (baggageHeader.length > 0) { - [currentRequest setValue:baggageHeader forHTTPHeaderField:SENTRY_BAGGAGE_HEADER]; - } + [SentryTracePropagation addHeaderFieldsToRequest:currentRequest + traceHeader:traceHeader + baggageHeader:baggageHeader + propagateTraceparent:propagateTraceparent]; } else { // Even though NSURLSessionTask doesn't have 'setCurrentRequest', some subclasses // do. For those subclasses we replace the currentRequest with a mutable one with @@ -49,14 +48,10 @@ + (void)addBaggageHeader:(SentryBaggage *)baggage SEL setCurrentRequestSelector = NSSelectorFromString(@"setCurrentRequest:"); if ([sessionTask respondsToSelector:setCurrentRequestSelector]) { NSMutableURLRequest *newRequest = [sessionTask.currentRequest mutableCopy]; - - if ([newRequest valueForHTTPHeaderField:SENTRY_TRACE_HEADER] == nil) { - [newRequest setValue:traceHeader.value forHTTPHeaderField:SENTRY_TRACE_HEADER]; - } - - if (baggageHeader.length > 0) { - [newRequest setValue:baggageHeader forHTTPHeaderField:SENTRY_BAGGAGE_HEADER]; - } + [SentryTracePropagation addHeaderFieldsToRequest:newRequest + traceHeader:traceHeader + baggageHeader:baggageHeader + propagateTraceparent:propagateTraceparent]; void (*func)(id, SEL, id param) = (void *)[sessionTask methodForSelector:setCurrentRequestSelector]; @@ -73,6 +68,29 @@ + (BOOL)sessionTaskRequiresPropagation:(NSURLSessionTask *)sessionTask withTargets:tracePropagationTargets]; } ++ (void)addHeaderFieldsToRequest:(NSMutableURLRequest *)request + traceHeader:(SentryTraceHeader *)traceHeader + baggageHeader:(NSString *)baggageHeader + propagateTraceparent:(BOOL)propagateTraceparent +{ + if ([request valueForHTTPHeaderField:SENTRY_TRACE_HEADER] == nil) { + [request setValue:traceHeader.value forHTTPHeaderField:SENTRY_TRACE_HEADER]; + } + + if (propagateTraceparent && [request valueForHTTPHeaderField:SENTRY_TRACEPARENT] == nil) { + + NSString *traceparent = [NSString stringWithFormat:@"00-%@-%@-%02x", + traceHeader.traceId.sentryIdString, traceHeader.spanId.sentrySpanIdString, + traceHeader.sampled == kSentrySampleDecisionYes ? 1 : 0]; + + [request setValue:traceparent forHTTPHeaderField:SENTRY_TRACEPARENT]; + } + + if (baggageHeader.length > 0) { + [request setValue:baggageHeader forHTTPHeaderField:SENTRY_BAGGAGE_HEADER]; + } +} + + (BOOL)isTargetMatch:(NSURL *)URL withTargets:(NSArray *)targets { for (id targetCheck in targets) { diff --git a/Sources/Sentry/SentyOptionsInternal.m b/Sources/Sentry/SentyOptionsInternal.m index b5330165a4..7aa3e150d6 100644 --- a/Sources/Sentry/SentyOptionsInternal.m +++ b/Sources/Sentry/SentyOptionsInternal.m @@ -382,6 +382,9 @@ + (BOOL)validateOptions:(NSDictionary *)options [self setBool:options[@"enableAutoBreadcrumbTracking"] block:^(BOOL value) { sentryOptions.enableAutoBreadcrumbTracking = value; }]; + [self setBool:options[@"enablePropagateTraceparent"] + block:^(BOOL value) { sentryOptions.enablePropagateTraceparent = value; }]; + if ([options[@"tracePropagationTargets"] isKindOfClass:[NSArray class]]) { sentryOptions.tracePropagationTargets = SENTRY_UNWRAP_NULLABLE(NSArray, options[@"tracePropagationTargets"]); diff --git a/Sources/Sentry/include/SentryTracePropagation.h b/Sources/Sentry/include/SentryTracePropagation.h index d99d18fe19..384e38adba 100644 --- a/Sources/Sentry/include/SentryTracePropagation.h +++ b/Sources/Sentry/include/SentryTracePropagation.h @@ -9,6 +9,7 @@ NS_ASSUME_NONNULL_BEGIN + (void)addBaggageHeader:(SentryBaggage *)baggage traceHeader:(SentryTraceHeader *)traceHeader + propagateTraceparent:(BOOL)propagateTraceparent tracePropagationTargets:(NSArray *)tracePropagationTargets toRequest:(NSURLSessionTask *)sessionTask; diff --git a/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift index ed2cc0cdb7..29a9dfb25e 100644 --- a/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift @@ -39,6 +39,7 @@ class SentryNetworkTrackerTests: XCTestCase { init() { options = Options() options.dsn = SentryNetworkTrackerTests.dsnAsString + options.enablePropagateTraceparent = true sentryTask = URLSessionDataTaskMock(request: URLRequest(url: URL(string: options.dsn!)!)) scope = Scope() client = TestClient(options: options) @@ -915,6 +916,50 @@ class SentryNetworkTrackerTests: XCTestCase { XCTAssertEqual(task.currentRequest?.allHTTPHeaderFields?["sentry-trace"] ?? "", "test") } + func testPropagateTraceparent() throws { + // Arrange + let sut = fixture.getSut() + let task = createDataTask() + let transaction = try XCTUnwrap(startTransaction() as? SentryTracer) + + // Act + sut.urlSessionTaskResume(task) + + // Assert + let children = try XCTUnwrap(Dynamic(transaction).children.asArray as? [SentrySpan]) + let networkSpan = try XCTUnwrap(children.first) + + let traceHeader = transaction.toTraceHeader() + let expectedTraceHeader = "00-\(traceHeader.traceId.sentryIdString)-\(networkSpan.spanId.sentrySpanIdString)-00" + XCTAssertEqual(task.currentRequest?.allHTTPHeaderFields?["traceparent"] ?? "", expectedTraceHeader) + } + + func testPropagateTraceparent_WhenDisabled_NotAdded() throws { + // Arrange + let sut = fixture.getSut() + let task = createDataTask() + _ = try XCTUnwrap(startTransaction() as? SentryTracer) + fixture.options.enablePropagateTraceparent = false + + // Act + sut.urlSessionTaskResume(task) + + // Assert + XCTAssertNil(task.currentRequest?.allHTTPHeaderFields?["traceparent"]) + } + + func testDontOverrideTraceparent() { + let sut = fixture.getSut() + let task = createDataTask { + var request = $0 + request.setValue("test", forHTTPHeaderField: "traceparent") + return request + } + sut.urlSessionTaskResume(task) + + XCTAssertEqual(task.currentRequest?.allHTTPHeaderFields?["traceparent"] ?? "", "test") + } + @available(*, deprecated) func testDefaultHeadersWhenDisabled() throws { let sut = fixture.getSut() diff --git a/Tests/SentryTests/Integrations/Performance/Network/SentryTracePropagationTests.swift b/Tests/SentryTests/Integrations/Performance/Network/SentryTracePropagationTests.swift index 983811ebe6..e12ced6aa4 100644 --- a/Tests/SentryTests/Integrations/Performance/Network/SentryTracePropagationTests.swift +++ b/Tests/SentryTests/Integrations/Performance/Network/SentryTracePropagationTests.swift @@ -2,6 +2,76 @@ import XCTest final class SentryTracePropagationTests: XCTestCase { + func testAddTraceparent_Sampled() throws { + // Arrange + let defaultRegex = try XCTUnwrap(NSRegularExpression(pattern: ".*")) + let emptyBaggage = Baggage() + let sessionTask = try createSessionTask() + + let traceID = SentryId() + let spanID = SpanId() + let traceHeader = TraceHeader(trace: traceID, spanId: spanID, sampled: SentrySampleDecision.yes) + + // Act + SentryTracePropagation.addBaggageHeader(emptyBaggage, traceHeader: traceHeader, propagateTraceparent: true, tracePropagationTargets: [defaultRegex], toRequest: sessionTask) + + // Assert + let traceParent = try XCTUnwrap(sessionTask.currentRequest?.allHTTPHeaderFields?["traceparent"]) + XCTAssertEqual(traceParent, "00-\(traceID.sentryIdString)-\(spanID.sentrySpanIdString)-01") + } + + func testAddTraceparent_NotSampled() throws { + // Arrange + let defaultRegex = try XCTUnwrap(NSRegularExpression(pattern: ".*")) + let emptyBaggage = Baggage() + let sessionTask = try createSessionTask() + + let traceID = SentryId() + let spanID = SpanId() + let traceHeader = TraceHeader(trace: traceID, spanId: spanID, sampled: SentrySampleDecision.no) + + // Act + SentryTracePropagation.addBaggageHeader(emptyBaggage, traceHeader: traceHeader, propagateTraceparent: true, tracePropagationTargets: [defaultRegex], toRequest: sessionTask) + + // Assert + let traceParent = try XCTUnwrap(sessionTask.currentRequest?.allHTTPHeaderFields?["traceparent"]) + XCTAssertEqual(traceParent, "00-\(traceID.sentryIdString)-\(spanID.sentrySpanIdString)-00") + } + + func testAddTraceparent_UndecidedSampled() throws { + // Arrange + let defaultRegex = try XCTUnwrap(NSRegularExpression(pattern: ".*")) + let emptyBaggage = Baggage() + let sessionTask = try createSessionTask() + + let traceID = SentryId() + let spanID = SpanId() + let traceHeader = TraceHeader(trace: traceID, spanId: spanID, sampled: SentrySampleDecision.undecided) + + // Act + SentryTracePropagation.addBaggageHeader(emptyBaggage, traceHeader: traceHeader, propagateTraceparent: true, tracePropagationTargets: [defaultRegex], toRequest: sessionTask) + + // Assert + let traceParent = try XCTUnwrap(sessionTask.currentRequest?.allHTTPHeaderFields?["traceparent"]) + XCTAssertEqual(traceParent, "00-\(traceID.sentryIdString)-\(spanID.sentrySpanIdString)-00") + } + + func testAddTraceparent_NotAddedWhenTargetDoesntMatch() throws { + // Arrange + let emptyBaggage = Baggage() + let sessionTask = try createSessionTask() + + let traceID = SentryId() + let spanID = SpanId() + let traceHeader = TraceHeader(trace: traceID, spanId: spanID, sampled: SentrySampleDecision.no) + + // Act + SentryTracePropagation.addBaggageHeader(emptyBaggage, traceHeader: traceHeader, propagateTraceparent: true, tracePropagationTargets: ["localhost"], toRequest: sessionTask) + + // Assert + XCTAssertNil(sessionTask.currentRequest?.allHTTPHeaderFields?["traceparent"]) + } + func testIsTargetMatchWithDefaultRegex_MatchesAllURLs() throws { // Arrange let defaultRegex = try XCTUnwrap(NSRegularExpression(pattern: ".*")) @@ -77,4 +147,11 @@ final class SentryTracePropagationTests: XCTestCase { XCTAssertTrue(SentryTracePropagation.isTargetMatch(localhostURL, withTargets: targetsWithInvalidType)) } + private func createSessionTask(method: String = "GET") throws -> URLSessionDownloadTaskMock { + let url = try XCTUnwrap(URL(string: "https://www.domain.com/api?query=value&query2=value2#fragment")) + var request = URLRequest(url: url) + request.httpMethod = method + return URLSessionDownloadTaskMock(request: request) + } + } diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index 142987b676..4046ed6b48 100644 --- a/Tests/SentryTests/SentryOptionsTest.m +++ b/Tests/SentryTests/SentryOptionsTest.m @@ -207,6 +207,11 @@ - (void)testEnableAutoBreadcrumbTracking [self testBooleanField:@"enableAutoBreadcrumbTracking"]; } +- (void)testEnablePropagateTraceparent +{ + [self testBooleanField:@"enablePropagateTraceparent" defaultValue:NO]; +} + - (void)testEnableCoreDataTracking { [self testBooleanField:@"enableCoreDataTracing" defaultValue:YES]; diff --git a/sdk_api.json b/sdk_api.json index b9af94d44a..fc6d73e9f7 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -20500,6 +20500,89 @@ } ] }, + { + "kind": "Var", + "name": "enablePropagateTraceparent", + "printedName": "enablePropagateTraceparent", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Var", + "usr": "c:objc(cs)SentryOptions(py)enablePropagateTraceparent", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "enablePropagateTraceparent", + "declAttributes": [ + "ObjC", + "Dynamic" + ], + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Accessor", + "usr": "c:objc(cs)SentryOptions(im)enablePropagateTraceparent", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "enablePropagateTraceparent", + "declAttributes": [ + "DiscardableResult", + "ObjC", + "Dynamic" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Accessor", + "usr": "c:objc(cs)SentryOptions(im)setEnablePropagateTraceparent:", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "setEnablePropagateTraceparent:", + "declAttributes": [ + "ObjC", + "Dynamic" + ], + "accessorKind": "set" + } + ] + }, { "kind": "Var", "name": "tracePropagationTargets",