From 0adc6a875abbbb1ed4eb53e30f4b00a15840a1e4 Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Tue, 10 Mar 2026 14:53:45 +0100 Subject: [PATCH 1/9] Add spanCustomization callback to Trace URLSession tracking Adds a `SpanCustomization` callback to `Trace.Configuration.URLSessionTracking` that allows users to customize spans for intercepted network requests. This enables adding request-specific tags (e.g., GraphQL operation name) or overriding the operation name based on the request content. The callback is invoked after default tags are set but before `span.finish()`, matching the existing `ResourceAttributesProvider` pattern in RUM. Closes #1649 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + .../TracingURLSessionHandler.swift | 6 + DatadogTrace/Sources/Trace.swift | 1 + DatadogTrace/Sources/TraceConfiguration.swift | 38 ++++- .../Tests/TracingURLSessionHandlerTests.swift | 146 ++++++++++++++++++ api-surface-swift | 4 +- 6 files changed, 194 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 6469cfce90..6c0cd83e56 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ artifacts/ # AI files .claude/settings.local.json +.rum-analysis/ # GSD planning files (local only) .planning/ diff --git a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift index a073b280c2..d09f57d25d 100644 --- a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift +++ b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift @@ -32,6 +32,8 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { let traceContextInjection: TraceContextInjection /// Telemetry interface for tracking SDK usage let telemetry: Telemetry + /// Optional callback to customize the span for each intercepted request. + let spanCustomization: Trace.Configuration.SpanCustomization? weak var tracer: DatadogTracer? @@ -54,6 +56,7 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { samplingRate: SampleRate, firstPartyHosts: FirstPartyHosts, traceContextInjection: TraceContextInjection, + spanCustomization: Trace.Configuration.SpanCustomization? = nil, telemetry: Telemetry ) { self.tracer = tracer @@ -61,6 +64,7 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { self.samplingRate = samplingRate self.firstPartyHosts = firstPartyHosts self.traceContextInjection = traceContextInjection + self.spanCustomization = spanCustomization self.telemetry = telemetry } @@ -315,6 +319,8 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { span.setTag(key: SpanTags.isBackground, value: didStartInBackground || doesEndInBackground) } + spanCustomization?(interception.request.unsafeOriginal, span) + span.finish(at: endTime) } diff --git a/DatadogTrace/Sources/Trace.swift b/DatadogTrace/Sources/Trace.swift index da61aba8d7..8c4e7ca5c7 100644 --- a/DatadogTrace/Sources/Trace.swift +++ b/DatadogTrace/Sources/Trace.swift @@ -67,6 +67,7 @@ public enum Trace { samplingRate: configuration.debugSDK ? 100 : tracingSampleRate, firstPartyHosts: firstPartyHosts, traceContextInjection: traceContextInjection, + spanCustomization: configuration.urlSessionTracking?.spanCustomization, telemetry: core.telemetry ) diff --git a/DatadogTrace/Sources/TraceConfiguration.swift b/DatadogTrace/Sources/TraceConfiguration.swift index 443a780808..b30d87db76 100644 --- a/DatadogTrace/Sources/TraceConfiguration.swift +++ b/DatadogTrace/Sources/TraceConfiguration.swift @@ -23,6 +23,26 @@ extension Trace { public struct Configuration: SampledTelemetry { public typealias EventMapper = (SpanEvent) -> SpanEvent + /// A callback that allows customizing the span created for each intercepted network request. + /// + /// This closure receives the original `URLRequest` and the `OTSpan` created for it. You can use + /// the span's `setTag(key:value:)` or `setOperationName(_:)` methods to add custom attributes. + /// + /// Example — tagging GraphQL requests with the operation name: + /// ```swift + /// Trace.Configuration.SpanCustomization { request, span in + /// if let body = request.httpBody, + /// let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + /// let operationName = json["operationName"] as? String { + /// span.setTag(key: "graphql.operation.name", value: operationName) + /// span.setOperationName("graphql.\(operationName)") + /// } + /// } + /// ``` + /// + /// - Note: Keep the implementation fast and do not make any assumptions on the thread used to run it. + public typealias SpanCustomization = (URLRequest, OTSpan) -> Void + /// The sampling rate for spans created with the default tracer. /// /// It must be a number between 0.0 and 100.0, where 0 means no spans will be collected. @@ -94,6 +114,17 @@ extension Trace { /// If your backend is also instrumented with Datadog, you will see the full trace (app → backend). public var firstPartyHostsTracing: FirstPartyHostsTracing + /// Custom span customization callback for intercepted network requests. + /// + /// This closure is called for each network request intercepted by the tracer, after the span is created + /// and default tags are set, but before the span is finished. Use it to add request-specific tags + /// (e.g., GraphQL operation name) or override the operation name. + /// + /// Keep the implementation fast and do not make any assumptions on the thread used to run it. + /// + /// Default: `nil`. + public var spanCustomization: SpanCustomization? + /// Defines configuration for first-party hosts in distributed tracing. public enum FirstPartyHostsTracing { /// Trace the specified hosts using Datadog and W3C `tracecontext` tracing headers. @@ -123,8 +154,13 @@ extension Trace { /// Configuration for automatic network requests tracing. /// - Parameters: /// - firstPartyHostsTracing: Distributed tracing configuration for particular first-party hosts. - public init(firstPartyHostsTracing: FirstPartyHostsTracing) { + /// - spanCustomization: Optional callback to customize spans for intercepted requests. + public init( + firstPartyHostsTracing: FirstPartyHostsTracing, + spanCustomization: SpanCustomization? = nil + ) { self.firstPartyHostsTracing = firstPartyHostsTracing + self.spanCustomization = spanCustomization } } diff --git a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift index 23f3853865..141eddc629 100644 --- a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift +++ b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift @@ -851,6 +851,152 @@ class TracingURLSessionHandlerTests: XCTestCase { XCTAssertEqual(span.resource, "404", "404 responses should have resource set to '404'") } + // MARK: - Span Customization Tests + + func testGivenSpanCustomization_whenInterceptionCompletes_itCallsCustomizationWithRequestAndSpan() throws { + let expectation = expectation(description: "Send span") + core.onEventWriteContext = { _ in expectation.fulfill() } + + var receivedRequest: URLRequest? + var receivedSpan: OTSpan? + + let handler = TracingURLSessionHandler( + tracer: tracer, + contextReceiver: ContextMessageReceiver(), + samplingRate: .maxSampleRate, + firstPartyHosts: .init([ + "www.example.com": [.datadog] + ]), + traceContextInjection: .all, + spanCustomization: { request, span in + receivedRequest = request + receivedSpan = span + span.setTag(key: "graphql.operation.name", value: "GetUser") + }, + telemetry: NOPTelemetry() + ) + + // Given + let request: ImmutableRequest = .mockWith( + url: URL(string: "https://www.example.com/graphql")!, + httpMethod: "POST" + ) + let interception = URLSessionTaskInterception(request: request, isFirstParty: true, trackingMode: .registeredDelegate) + interception.register(response: .mockResponseWith(statusCode: 200), error: nil) + interception.register( + metrics: .mockWith( + fetch: .init( + start: .mockDecember15th2019At10AMUTC(), + end: .mockDecember15th2019At10AMUTC(addingTimeInterval: 1) + ) + ) + ) + + // When + handler.interceptionDidComplete(interception: interception) + + // Then + waitForExpectations(timeout: 0.5, handler: nil) + + XCTAssertNotNil(receivedRequest, "Customization callback should receive the request") + XCTAssertEqual(receivedRequest?.url?.absoluteString, "https://www.example.com/graphql") + XCTAssertEqual(receivedRequest?.httpMethod, "POST") + XCTAssertNotNil(receivedSpan, "Customization callback should receive the span") + + let envelope: SpanEventsEnvelope? = core.events().last + let span = try XCTUnwrap(envelope?.spans.first) + XCTAssertEqual(span.tags["graphql.operation.name"], "GetUser") + } + + func testGivenNoSpanCustomization_whenInterceptionCompletes_itCreatesSpanNormally() throws { + let expectation = expectation(description: "Send span") + core.onEventWriteContext = { _ in expectation.fulfill() } + + let handler = TracingURLSessionHandler( + tracer: tracer, + contextReceiver: ContextMessageReceiver(), + samplingRate: .maxSampleRate, + firstPartyHosts: .init([ + "www.example.com": [.datadog] + ]), + traceContextInjection: .all, + spanCustomization: nil, + telemetry: NOPTelemetry() + ) + + // Given + let interception = URLSessionTaskInterception(request: .mockAny(), isFirstParty: true, trackingMode: .registeredDelegate) + interception.register(response: .mockResponseWith(statusCode: 200), error: nil) + interception.register( + metrics: .mockWith( + fetch: .init( + start: .mockDecember15th2019At10AMUTC(), + end: .mockDecember15th2019At10AMUTC(addingTimeInterval: 1) + ) + ) + ) + + // When + handler.interceptionDidComplete(interception: interception) + + // Then + waitForExpectations(timeout: 0.5, handler: nil) + + let envelope: SpanEventsEnvelope? = core.events().last + let span = try XCTUnwrap(envelope?.spans.first) + XCTAssertEqual(span.operationName, "urlsession.request") + XCTAssertFalse(span.isError) + } + + func testGivenSpanCustomization_whenInterceptionCompletes_customTagsCoexistWithDefaultTags() throws { + let expectation = expectation(description: "Send span") + core.onEventWriteContext = { _ in expectation.fulfill() } + + let handler = TracingURLSessionHandler( + tracer: tracer, + contextReceiver: ContextMessageReceiver(), + samplingRate: .maxSampleRate, + firstPartyHosts: .init([ + "www.example.com": [.datadog] + ]), + traceContextInjection: .all, + spanCustomization: { _, span in + span.setTag(key: "custom.tag", value: "custom_value") + span.setOperationName("graphql.query") + }, + telemetry: NOPTelemetry() + ) + + // Given + let request: ImmutableRequest = .mockWith(httpMethod: "POST") + let interception = URLSessionTaskInterception(request: request, isFirstParty: true, trackingMode: .registeredDelegate) + interception.register(response: .mockResponseWith(statusCode: 200), error: nil) + interception.register( + metrics: .mockWith( + fetch: .init( + start: .mockDecember15th2019At10AMUTC(), + end: .mockDecember15th2019At10AMUTC(addingTimeInterval: 1) + ) + ) + ) + + // When + handler.interceptionDidComplete(interception: interception) + + // Then + waitForExpectations(timeout: 0.5, handler: nil) + + let envelope: SpanEventsEnvelope? = core.events().last + let span = try XCTUnwrap(envelope?.spans.first) + // Custom tags set via callback + XCTAssertEqual(span.tags["custom.tag"], "custom_value") + XCTAssertEqual(span.operationName, "graphql.query") + // Default tags still present + XCTAssertEqual(span.tags[OTTags.httpMethod], "POST") + XCTAssertEqual(span.tags[OTTags.httpStatusCode], "200") + XCTAssertEqual(span.tags[OTTags.spanKind], "client") + } + private func assert(capturedState: URLSessionHandlerCapturedState?, has span: OTSpan?) { guard let state = capturedState as? TracingURLSessionHandler.TracingURLSessionHandlerCapturedState else { XCTFail("Expected TracingURLSessionHandlerCapturedState instance, got \(String(describing: capturedState))") diff --git a/api-surface-swift b/api-surface-swift index a515436b49..569435c642 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -311,12 +311,14 @@ public enum Trace public var networkInfoEnabled: Bool public var eventMapper: EventMapper? public var customEndpoint: URL? + public typealias SpanCustomization = (URLRequest, OTSpan) -> Void public struct URLSessionTracking public var firstPartyHostsTracing: FirstPartyHostsTracing + public var spanCustomization: SpanCustomization? public enum FirstPartyHostsTracing case trace(hosts: Set,sampleRate: Float = .maxSampleRate,traceControlInjection: TraceContextInjection = .sampled) case traceWithHeaders(hostsWithHeaders: [String: Set],sampleRate: Float = .maxSampleRate,traceControlInjection: TraceContextInjection = .sampled) - public init(firstPartyHostsTracing: FirstPartyHostsTracing) + public init(firstPartyHostsTracing: FirstPartyHostsTracing,spanCustomization: SpanCustomization? = nil) public init(sampleRate: SampleRate = .maxSampleRate,service: String? = nil,tags: [String: Encodable]? = nil,urlSessionTracking: URLSessionTracking? = nil,bundleWithRumEnabled: Bool = true,networkInfoEnabled: Bool = false,eventMapper: EventMapper? = nil,customEndpoint: URL? = nil) public enum SpanTags public static let resource = "resource.name" From aed358d22fd8613738ef87f51f8631f8fa850b23 Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Tue, 10 Mar 2026 15:17:52 +0100 Subject: [PATCH 2/9] Add response and error parameters to SpanCustomization for Android parity Updates SpanCustomization signature from (URLRequest, OTSpan) to (URLRequest, OTSpan, URLResponse?, Error?) to match the Android SDK's TracedRequestListener.onRequestIntercepted(Request, DatadogSpan, Response?, Throwable?) interface, providing full request lifecycle context in the callback. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Integrations/TracingURLSessionHandler.swift | 7 ++++++- DatadogTrace/Sources/TraceConfiguration.swift | 9 +++++---- .../Tests/TracingURLSessionHandlerTests.swift | 13 ++++++++++--- api-surface-swift | 2 +- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift index d09f57d25d..872a4b0fcb 100644 --- a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift +++ b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift @@ -319,7 +319,12 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { span.setTag(key: SpanTags.isBackground, value: didStartInBackground || doesEndInBackground) } - spanCustomization?(interception.request.unsafeOriginal, span) + spanCustomization?( + interception.request.unsafeOriginal, + span, + resourceCompletion.httpResponse, + resourceCompletion.error + ) span.finish(at: endTime) } diff --git a/DatadogTrace/Sources/TraceConfiguration.swift b/DatadogTrace/Sources/TraceConfiguration.swift index b30d87db76..82c6676234 100644 --- a/DatadogTrace/Sources/TraceConfiguration.swift +++ b/DatadogTrace/Sources/TraceConfiguration.swift @@ -25,12 +25,13 @@ extension Trace { /// A callback that allows customizing the span created for each intercepted network request. /// - /// This closure receives the original `URLRequest` and the `OTSpan` created for it. You can use - /// the span's `setTag(key:value:)` or `setOperationName(_:)` methods to add custom attributes. + /// This closure receives the original `URLRequest`, the `OTSpan` created for it, the HTTP response + /// (if any), and an error (if any). You can use the span's `setTag(key:value:)` or + /// `setOperationName(_:)` methods to add custom attributes. /// /// Example — tagging GraphQL requests with the operation name: /// ```swift - /// Trace.Configuration.SpanCustomization { request, span in + /// Trace.Configuration.SpanCustomization { request, span, response, error in /// if let body = request.httpBody, /// let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], /// let operationName = json["operationName"] as? String { @@ -41,7 +42,7 @@ extension Trace { /// ``` /// /// - Note: Keep the implementation fast and do not make any assumptions on the thread used to run it. - public typealias SpanCustomization = (URLRequest, OTSpan) -> Void + public typealias SpanCustomization = (URLRequest, OTSpan, URLResponse?, Error?) -> Void /// The sampling rate for spans created with the default tracer. /// diff --git a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift index 141eddc629..f1963d7867 100644 --- a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift +++ b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift @@ -853,12 +853,14 @@ class TracingURLSessionHandlerTests: XCTestCase { // MARK: - Span Customization Tests - func testGivenSpanCustomization_whenInterceptionCompletes_itCallsCustomizationWithRequestAndSpan() throws { + func testGivenSpanCustomization_whenInterceptionCompletes_itCallsCustomizationWithAllParameters() throws { let expectation = expectation(description: "Send span") core.onEventWriteContext = { _ in expectation.fulfill() } var receivedRequest: URLRequest? var receivedSpan: OTSpan? + var receivedResponse: URLResponse? + var receivedError: Error? let handler = TracingURLSessionHandler( tracer: tracer, @@ -868,9 +870,11 @@ class TracingURLSessionHandlerTests: XCTestCase { "www.example.com": [.datadog] ]), traceContextInjection: .all, - spanCustomization: { request, span in + spanCustomization: { request, span, response, error in receivedRequest = request receivedSpan = span + receivedResponse = response + receivedError = error span.setTag(key: "graphql.operation.name", value: "GetUser") }, telemetry: NOPTelemetry() @@ -902,6 +906,9 @@ class TracingURLSessionHandlerTests: XCTestCase { XCTAssertEqual(receivedRequest?.url?.absoluteString, "https://www.example.com/graphql") XCTAssertEqual(receivedRequest?.httpMethod, "POST") XCTAssertNotNil(receivedSpan, "Customization callback should receive the span") + XCTAssertNotNil(receivedResponse, "Customization callback should receive the response") + XCTAssertEqual((receivedResponse as? HTTPURLResponse)?.statusCode, 200) + XCTAssertNil(receivedError, "Error should be nil for successful requests") let envelope: SpanEventsEnvelope? = core.events().last let span = try XCTUnwrap(envelope?.spans.first) @@ -960,7 +967,7 @@ class TracingURLSessionHandlerTests: XCTestCase { "www.example.com": [.datadog] ]), traceContextInjection: .all, - spanCustomization: { _, span in + spanCustomization: { _, span, _, _ in span.setTag(key: "custom.tag", value: "custom_value") span.setOperationName("graphql.query") }, diff --git a/api-surface-swift b/api-surface-swift index 569435c642..21166fe893 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -311,7 +311,7 @@ public enum Trace public var networkInfoEnabled: Bool public var eventMapper: EventMapper? public var customEndpoint: URL? - public typealias SpanCustomization = (URLRequest, OTSpan) -> Void + public typealias SpanCustomization = (URLRequest, OTSpan, URLResponse?, Error?) -> Void public struct URLSessionTracking public var firstPartyHostsTracing: FirstPartyHostsTracing public var spanCustomization: SpanCustomization? From fc36c9ef960ae9bd380c87b100b3492b5a09d02e Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Tue, 10 Mar 2026 16:43:05 +0100 Subject: [PATCH 3/9] Fix swiftlint: move default parameter to end of parameter list Moves spanCustomization (which has a default value) after telemetry to satisfy the function_default_parameter_at_end lint rule. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/Integrations/TracingURLSessionHandler.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift index 872a4b0fcb..c332437fcd 100644 --- a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift +++ b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift @@ -56,16 +56,16 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { samplingRate: SampleRate, firstPartyHosts: FirstPartyHosts, traceContextInjection: TraceContextInjection, - spanCustomization: Trace.Configuration.SpanCustomization? = nil, - telemetry: Telemetry + telemetry: Telemetry, + spanCustomization: Trace.Configuration.SpanCustomization? = nil ) { self.tracer = tracer self.contextReceiver = contextReceiver self.samplingRate = samplingRate self.firstPartyHosts = firstPartyHosts self.traceContextInjection = traceContextInjection - self.spanCustomization = spanCustomization self.telemetry = telemetry + self.spanCustomization = spanCustomization } func modify(request: URLRequest, headerTypes: Set, networkContext: NetworkContext?) -> (URLRequest, TraceContext?, URLSessionHandlerCapturedState?) { From aab4d5e2d1a799cb86f3a7a9b06093c62c51172c Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Thu, 12 Mar 2026 16:11:03 +0100 Subject: [PATCH 4/9] Fix argument order in TracingURLSessionHandler init call --- DatadogTrace/Sources/Trace.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DatadogTrace/Sources/Trace.swift b/DatadogTrace/Sources/Trace.swift index 8c4e7ca5c7..9d77ba0d42 100644 --- a/DatadogTrace/Sources/Trace.swift +++ b/DatadogTrace/Sources/Trace.swift @@ -67,8 +67,8 @@ public enum Trace { samplingRate: configuration.debugSDK ? 100 : tracingSampleRate, firstPartyHosts: firstPartyHosts, traceContextInjection: traceContextInjection, - spanCustomization: configuration.urlSessionTracking?.spanCustomization, - telemetry: core.telemetry + telemetry: core.telemetry, + spanCustomization: configuration.urlSessionTracking?.spanCustomization ) try core.register(urlSessionHandler: urlSessionHandler) From fb1a5dd2d685c8cc6f05372ff837761bbe7d5f75 Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Thu, 12 Mar 2026 16:45:32 +0100 Subject: [PATCH 5/9] Fix argument order in TracingURLSessionHandler test call sites --- .../Tests/TracingURLSessionHandlerTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift index f1963d7867..09590974b4 100644 --- a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift +++ b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift @@ -870,14 +870,14 @@ class TracingURLSessionHandlerTests: XCTestCase { "www.example.com": [.datadog] ]), traceContextInjection: .all, + telemetry: NOPTelemetry(), spanCustomization: { request, span, response, error in receivedRequest = request receivedSpan = span receivedResponse = response receivedError = error span.setTag(key: "graphql.operation.name", value: "GetUser") - }, - telemetry: NOPTelemetry() + } ) // Given @@ -927,8 +927,8 @@ class TracingURLSessionHandlerTests: XCTestCase { "www.example.com": [.datadog] ]), traceContextInjection: .all, - spanCustomization: nil, - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + spanCustomization: nil ) // Given @@ -967,11 +967,11 @@ class TracingURLSessionHandlerTests: XCTestCase { "www.example.com": [.datadog] ]), traceContextInjection: .all, + telemetry: NOPTelemetry(), spanCustomization: { _, span, _, _ in span.setTag(key: "custom.tag", value: "custom_value") span.setOperationName("graphql.query") - }, - telemetry: NOPTelemetry() + } ) // Given From b728e345db82efd94ff26bc1a8818ff6a948bedf Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Fri, 13 Mar 2026 09:01:16 +0100 Subject: [PATCH 6/9] Address PR review feedback - Merge custom-tags-coexist test into first span customization test - Replace merged test with error-only span customization test - Reduce doc repetition: property doc now references typealias via SeeAlso --- DatadogTrace/Sources/TraceConfiguration.swift | 10 +---- .../Tests/TracingURLSessionHandlerTests.swift | 38 ++++++++++++------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/DatadogTrace/Sources/TraceConfiguration.swift b/DatadogTrace/Sources/TraceConfiguration.swift index 82c6676234..d4224449e7 100644 --- a/DatadogTrace/Sources/TraceConfiguration.swift +++ b/DatadogTrace/Sources/TraceConfiguration.swift @@ -115,15 +115,9 @@ extension Trace { /// If your backend is also instrumented with Datadog, you will see the full trace (app → backend). public var firstPartyHostsTracing: FirstPartyHostsTracing - /// Custom span customization callback for intercepted network requests. + /// Optional callback to customize spans for intercepted network requests. /// - /// This closure is called for each network request intercepted by the tracer, after the span is created - /// and default tags are set, but before the span is finished. Use it to add request-specific tags - /// (e.g., GraphQL operation name) or override the operation name. - /// - /// Keep the implementation fast and do not make any assumptions on the thread used to run it. - /// - /// Default: `nil`. + /// - SeeAlso: ``SpanCustomization`` public var spanCustomization: SpanCustomization? /// Defines configuration for first-party hosts in distributed tracing. diff --git a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift index 09590974b4..5f8bed19b1 100644 --- a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift +++ b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift @@ -912,7 +912,12 @@ class TracingURLSessionHandlerTests: XCTestCase { let envelope: SpanEventsEnvelope? = core.events().last let span = try XCTUnwrap(envelope?.spans.first) + // Custom tags set via callback XCTAssertEqual(span.tags["graphql.operation.name"], "GetUser") + // Default tags still present + XCTAssertEqual(span.tags[OTTags.httpMethod], "POST") + XCTAssertEqual(span.tags[OTTags.httpStatusCode], "200") + XCTAssertEqual(span.tags[OTTags.spanKind], "client") } func testGivenNoSpanCustomization_whenInterceptionCompletes_itCreatesSpanNormally() throws { @@ -955,10 +960,13 @@ class TracingURLSessionHandlerTests: XCTestCase { XCTAssertFalse(span.isError) } - func testGivenSpanCustomization_whenInterceptionCompletes_customTagsCoexistWithDefaultTags() throws { + func testGivenSpanCustomization_whenInterceptionCompletesWithError_itCallsCustomizationWithError() throws { let expectation = expectation(description: "Send span") core.onEventWriteContext = { _ in expectation.fulfill() } + var receivedResponse: URLResponse? + var receivedError: Error? + let handler = TracingURLSessionHandler( tracer: tracer, contextReceiver: ContextMessageReceiver(), @@ -968,16 +976,21 @@ class TracingURLSessionHandlerTests: XCTestCase { ]), traceContextInjection: .all, telemetry: NOPTelemetry(), - spanCustomization: { _, span, _, _ in - span.setTag(key: "custom.tag", value: "custom_value") - span.setOperationName("graphql.query") + spanCustomization: { _, span, response, error in + receivedResponse = response + receivedError = error + span.setTag(key: "custom.error.tag", value: "handled") } ) // Given - let request: ImmutableRequest = .mockWith(httpMethod: "POST") + let request: ImmutableRequest = .mockWith( + url: URL(string: "https://www.example.com/api")!, + httpMethod: "GET" + ) + let networkError = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut, userInfo: nil) let interception = URLSessionTaskInterception(request: request, isFirstParty: true, trackingMode: .registeredDelegate) - interception.register(response: .mockResponseWith(statusCode: 200), error: nil) + interception.register(response: nil, error: networkError) interception.register( metrics: .mockWith( fetch: .init( @@ -993,15 +1006,14 @@ class TracingURLSessionHandlerTests: XCTestCase { // Then waitForExpectations(timeout: 0.5, handler: nil) + XCTAssertNil(receivedResponse, "Response should be nil for error-only requests") + XCTAssertNotNil(receivedError, "Customization callback should receive the error") + XCTAssertEqual((receivedError as? NSError)?.code, NSURLErrorTimedOut) + let envelope: SpanEventsEnvelope? = core.events().last let span = try XCTUnwrap(envelope?.spans.first) - // Custom tags set via callback - XCTAssertEqual(span.tags["custom.tag"], "custom_value") - XCTAssertEqual(span.operationName, "graphql.query") - // Default tags still present - XCTAssertEqual(span.tags[OTTags.httpMethod], "POST") - XCTAssertEqual(span.tags[OTTags.httpStatusCode], "200") - XCTAssertEqual(span.tags[OTTags.spanKind], "client") + XCTAssertTrue(span.isError) + XCTAssertEqual(span.tags["custom.error.tag"], "handled") } private func assert(capturedState: URLSessionHandlerCapturedState?, has span: OTSpan?) { From 71b7078cee04b5df7249b66fe3d8d723c7eaac75 Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Tue, 17 Mar 2026 09:42:43 +0100 Subject: [PATCH 7/9] Use ImmutableRequest instead of unsafeOriginal in SpanCustomization Pass the thread-safe ImmutableRequest snapshot to the SpanCustomization callback instead of the raw URLRequest via unsafeOriginal, avoiding potential crashes from concurrent access to URLRequest's bridged NSMutableDictionary internals on iOS 12-25. Co-Authored-By: Claude Opus 4.6 --- .../Integrations/TracingURLSessionHandler.swift | 2 +- DatadogTrace/Sources/TraceConfiguration.swift | 17 +++++++---------- .../Tests/TracingURLSessionHandlerTests.swift | 2 +- api-surface-swift | 2 +- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift index c332437fcd..b9a19585cb 100644 --- a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift +++ b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift @@ -320,7 +320,7 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { } spanCustomization?( - interception.request.unsafeOriginal, + interception.request, span, resourceCompletion.httpResponse, resourceCompletion.error diff --git a/DatadogTrace/Sources/TraceConfiguration.swift b/DatadogTrace/Sources/TraceConfiguration.swift index d4224449e7..4a5b8ebf5b 100644 --- a/DatadogTrace/Sources/TraceConfiguration.swift +++ b/DatadogTrace/Sources/TraceConfiguration.swift @@ -25,24 +25,21 @@ extension Trace { /// A callback that allows customizing the span created for each intercepted network request. /// - /// This closure receives the original `URLRequest`, the `OTSpan` created for it, the HTTP response - /// (if any), and an error (if any). You can use the span's `setTag(key:value:)` or - /// `setOperationName(_:)` methods to add custom attributes. + /// This closure receives an ``ImmutableRequest`` (a thread-safe snapshot of the original request), + /// the ``OTSpan`` created for it, the HTTP response (if any), and an error (if any). You can use + /// the span's `setTag(key:value:)` or `setOperationName(_:)` methods to add custom attributes. /// - /// Example — tagging GraphQL requests with the operation name: + /// Example — tagging requests by URL path: /// ```swift /// Trace.Configuration.SpanCustomization { request, span, response, error in - /// if let body = request.httpBody, - /// let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], - /// let operationName = json["operationName"] as? String { - /// span.setTag(key: "graphql.operation.name", value: operationName) - /// span.setOperationName("graphql.\(operationName)") + /// if let url = request.url { + /// span.setTag(key: "http.route", value: url.path) /// } /// } /// ``` /// /// - Note: Keep the implementation fast and do not make any assumptions on the thread used to run it. - public typealias SpanCustomization = (URLRequest, OTSpan, URLResponse?, Error?) -> Void + public typealias SpanCustomization = (ImmutableRequest, OTSpan, URLResponse?, Error?) -> Void /// The sampling rate for spans created with the default tracer. /// diff --git a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift index 5f8bed19b1..10f6a8cf21 100644 --- a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift +++ b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift @@ -857,7 +857,7 @@ class TracingURLSessionHandlerTests: XCTestCase { let expectation = expectation(description: "Send span") core.onEventWriteContext = { _ in expectation.fulfill() } - var receivedRequest: URLRequest? + var receivedRequest: ImmutableRequest? var receivedSpan: OTSpan? var receivedResponse: URLResponse? var receivedError: Error? diff --git a/api-surface-swift b/api-surface-swift index 21166fe893..1db0f9a785 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -311,7 +311,7 @@ public enum Trace public var networkInfoEnabled: Bool public var eventMapper: EventMapper? public var customEndpoint: URL? - public typealias SpanCustomization = (URLRequest, OTSpan, URLResponse?, Error?) -> Void + public typealias SpanCustomization = (ImmutableRequest, OTSpan, URLResponse?, Error?) -> Void public struct URLSessionTracking public var firstPartyHostsTracing: FirstPartyHostsTracing public var spanCustomization: SpanCustomization? From ae9e142fd2e78cffad261220dd9ee171de574f7a Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Fri, 27 Mar 2026 10:19:54 +0100 Subject: [PATCH 8/9] [RUM-1649] Replace ImmutableRequest with InterceptedRequest in SpanCustomization callback - Add Trace.Configuration.InterceptedRequest (url, httpMethod, httpBody) as a purpose-built public type in DatadogTrace, replacing the internal ImmutableRequest - Remove ImmutableRequest from the public API surface; InterceptedRequest has no unsafeOriginal property so users cannot reach the underlying URLRequest - Add httpBody support (backed by immutable NSData, safe for concurrent reads) - Update SpanCustomization typealias, TracingURLSessionHandler, api-surface-swift - Add ImmutableRequest.mockWith(httpBody:) parameter to TestUtilities - Add tests: nil body, GraphQL decoding use-case, and concurrent completions stress test that verifies thread safety of InterceptedRequest property reads --- Datadog/Datadog.xcodeproj/project.pbxproj | 6 + .../TracingURLSessionHandler.swift | 2 +- DatadogTrace/Sources/InterceptedRequest.swift | 33 ++++ DatadogTrace/Sources/TraceConfiguration.swift | 18 +- .../Tests/TracingURLSessionHandlerTests.swift | 182 +++++++++++++++++- .../NetworkInstrumentationMocks.swift | 2 + api-surface-swift | 6 +- 7 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 DatadogTrace/Sources/InterceptedRequest.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 6d7fce5c4f..a0b77d7e22 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -978,6 +978,8 @@ 61A2CC342A44A6030000FF25 /* DatadogRUM.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D23F8E9929DDCD28001CFAE8 /* DatadogRUM.framework */; }; 61A2CC362A44B0A20000FF25 /* TraceConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A2CC352A44B0A20000FF25 /* TraceConfiguration.swift */; }; 61A2CC372A44B0A20000FF25 /* TraceConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A2CC352A44B0A20000FF25 /* TraceConfiguration.swift */; }; + 7F859F0296CB41419B09C221 /* InterceptedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4209932100E4E73BADD5A3D /* InterceptedRequest.swift */; }; + 817358634A3541559D67A261 /* InterceptedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4209932100E4E73BADD5A3D /* InterceptedRequest.swift */; }; 61A2CC392A44B0EA0000FF25 /* Trace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A2CC382A44B0EA0000FF25 /* Trace.swift */; }; 61A2CC3A2A44B0EA0000FF25 /* Trace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A2CC382A44B0EA0000FF25 /* Trace.swift */; }; 61A2CC3C2A44BED30000FF25 /* Tracer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A2CC3B2A44BED30000FF25 /* Tracer.swift */; }; @@ -3400,6 +3402,7 @@ 61A2CC202A443D330000FF25 /* DDRUMConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDRUMConfigurationTests.swift; sourceTree = ""; }; 61A2CC232A44454D0000FF25 /* DDRUMTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDRUMTests.swift; sourceTree = ""; }; 61A2CC352A44B0A20000FF25 /* TraceConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceConfiguration.swift; sourceTree = ""; }; + C4209932100E4E73BADD5A3D /* InterceptedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterceptedRequest.swift; sourceTree = ""; }; 61A2CC382A44B0EA0000FF25 /* Trace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trace.swift; sourceTree = ""; }; 61A2CC3B2A44BED30000FF25 /* Tracer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracer.swift; sourceTree = ""; }; 61A614E7276B2BD000A06CE7 /* RUMOffViewEventsHandlingRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMOffViewEventsHandlingRule.swift; sourceTree = ""; }; @@ -7407,6 +7410,7 @@ 61C5A87E24509A0C00DA608C /* DDSpanContext.swift */, 61A2CC382A44B0EA0000FF25 /* Trace.swift */, 61A2CC352A44B0A20000FF25 /* TraceConfiguration.swift */, + C4209932100E4E73BADD5A3D /* InterceptedRequest.swift */, 61A2CC3B2A44BED30000FF25 /* Tracer.swift */, 09D2B7EA2EF4258D0089F05B /* SamplingDecision.swift */, D2546C0629AF55CE0054E00B /* Feature */, @@ -10987,6 +10991,7 @@ D2C1A4FB29C4C4CB00946C31 /* MessageReceivers.swift in Sources */, 3C32359D2B55386C000B4258 /* OTelSpanLink.swift in Sources */, 61A2CC362A44B0A20000FF25 /* TraceConfiguration.swift in Sources */, + 7F859F0296CB41419B09C221 /* InterceptedRequest.swift in Sources */, 61A2CC392A44B0EA0000FF25 /* Trace.swift in Sources */, D2C1A50029C4C4CB00946C31 /* ActiveSpansPool.swift in Sources */, 3CB012DF2B482E0400557951 /* NOPOTelSpanBuilder.swift in Sources */, @@ -11296,6 +11301,7 @@ D2C1A54129C4F2DF00946C31 /* MessageReceivers.swift in Sources */, 3C32359E2B55386C000B4258 /* OTelSpanLink.swift in Sources */, 61A2CC372A44B0A20000FF25 /* TraceConfiguration.swift in Sources */, + 817358634A3541559D67A261 /* InterceptedRequest.swift in Sources */, 61A2CC3A2A44B0EA0000FF25 /* Trace.swift in Sources */, D2C1A54229C4F2DF00946C31 /* ActiveSpansPool.swift in Sources */, 3CB012E02B482E0400557951 /* NOPOTelSpanBuilder.swift in Sources */, diff --git a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift index b9a19585cb..8c236cb024 100644 --- a/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift +++ b/DatadogTrace/Sources/Integrations/TracingURLSessionHandler.swift @@ -320,7 +320,7 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { } spanCustomization?( - interception.request, + .init(from: interception.request), span, resourceCompletion.httpResponse, resourceCompletion.error diff --git a/DatadogTrace/Sources/InterceptedRequest.swift b/DatadogTrace/Sources/InterceptedRequest.swift new file mode 100644 index 0000000000..236b23133e --- /dev/null +++ b/DatadogTrace/Sources/InterceptedRequest.swift @@ -0,0 +1,33 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +extension Trace.Configuration { + /// An immutable snapshot of a network request, passed to ``SpanCustomization`` callbacks. + /// + /// Properties are copied from the original `URLRequest` at interception time and are safe to read + /// from any thread. + public struct InterceptedRequest { + /// The URL of the request. + public let url: URL? + /// The HTTP method of the request (e.g. `"GET"`, `"POST"`). + public let httpMethod: String? + /// The body data of the request. + public let httpBody: Data? + } +} + +internal extension Trace.Configuration.InterceptedRequest { + init(from request: ImmutableRequest) { + self.url = request.url + self.httpMethod = request.httpMethod + // httpBody is Data? — safe to access; the thread-safety concern in ImmutableRequest + // applies only to allHTTPHeaderFields (bridged NSMutableDictionary). + self.httpBody = request.unsafeOriginal.httpBody + } +} diff --git a/DatadogTrace/Sources/TraceConfiguration.swift b/DatadogTrace/Sources/TraceConfiguration.swift index 4a5b8ebf5b..af829822e1 100644 --- a/DatadogTrace/Sources/TraceConfiguration.swift +++ b/DatadogTrace/Sources/TraceConfiguration.swift @@ -25,21 +25,25 @@ extension Trace { /// A callback that allows customizing the span created for each intercepted network request. /// - /// This closure receives an ``ImmutableRequest`` (a thread-safe snapshot of the original request), - /// the ``OTSpan`` created for it, the HTTP response (if any), and an error (if any). You can use - /// the span's `setTag(key:value:)` or `setOperationName(_:)` methods to add custom attributes. + /// This closure receives an ``InterceptedRequest`` (a thread-safe snapshot of the original + /// request), the ``OTSpan`` created for it, the HTTP response (if any), and an error (if any). + /// You can use the span's `setTag(key:value:)` or `setOperationName(_:)` methods to add custom + /// attributes. /// - /// Example — tagging requests by URL path: + /// Example — tagging GraphQL requests with the operation name: /// ```swift /// Trace.Configuration.SpanCustomization { request, span, response, error in - /// if let url = request.url { - /// span.setTag(key: "http.route", value: url.path) + /// if let body = request.httpBody, + /// let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + /// let operationName = json["operationName"] as? String { + /// span.setTag(key: "graphql.operation.name", value: operationName) + /// span.setOperationName("graphql.\(operationName)") /// } /// } /// ``` /// /// - Note: Keep the implementation fast and do not make any assumptions on the thread used to run it. - public typealias SpanCustomization = (ImmutableRequest, OTSpan, URLResponse?, Error?) -> Void + public typealias SpanCustomization = (InterceptedRequest, OTSpan, URLResponse?, Error?) -> Void /// The sampling rate for spans created with the default tracer. /// diff --git a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift index 10f6a8cf21..e365c2004b 100644 --- a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift +++ b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift @@ -857,7 +857,7 @@ class TracingURLSessionHandlerTests: XCTestCase { let expectation = expectation(description: "Send span") core.onEventWriteContext = { _ in expectation.fulfill() } - var receivedRequest: ImmutableRequest? + var receivedRequest: Trace.Configuration.InterceptedRequest? var receivedSpan: OTSpan? var receivedResponse: URLResponse? var receivedError: Error? @@ -881,9 +881,11 @@ class TracingURLSessionHandlerTests: XCTestCase { ) // Given + let requestBody = #"{"operationName":"GetUser"}"#.data(using: .utf8) let request: ImmutableRequest = .mockWith( url: URL(string: "https://www.example.com/graphql")!, - httpMethod: "POST" + httpMethod: "POST", + httpBody: requestBody ) let interception = URLSessionTaskInterception(request: request, isFirstParty: true, trackingMode: .registeredDelegate) interception.register(response: .mockResponseWith(statusCode: 200), error: nil) @@ -905,6 +907,7 @@ class TracingURLSessionHandlerTests: XCTestCase { XCTAssertNotNil(receivedRequest, "Customization callback should receive the request") XCTAssertEqual(receivedRequest?.url?.absoluteString, "https://www.example.com/graphql") XCTAssertEqual(receivedRequest?.httpMethod, "POST") + XCTAssertEqual(receivedRequest?.httpBody, requestBody, "Customization callback should receive the request body") XCTAssertNotNil(receivedSpan, "Customization callback should receive the span") XCTAssertNotNil(receivedResponse, "Customization callback should receive the response") XCTAssertEqual((receivedResponse as? HTTPURLResponse)?.statusCode, 200) @@ -1016,6 +1019,181 @@ class TracingURLSessionHandlerTests: XCTestCase { XCTAssertEqual(span.tags["custom.error.tag"], "handled") } + func testGivenSpanCustomization_whenRequestHasNoBody_itReceivesNilHttpBody() throws { + let expectation = expectation(description: "Send span") + core.onEventWriteContext = { _ in expectation.fulfill() } + + var receivedHttpBody: Data? = Data() // non-nil sentinel to detect it was set + + let handler = TracingURLSessionHandler( + tracer: tracer, + contextReceiver: ContextMessageReceiver(), + samplingRate: .maxSampleRate, + firstPartyHosts: .init([ + "www.example.com": [.datadog] + ]), + traceContextInjection: .all, + telemetry: NOPTelemetry(), + spanCustomization: { request, _, _, _ in + receivedHttpBody = request.httpBody + } + ) + + // Given - request with no body + let request: ImmutableRequest = .mockWith( + url: URL(string: "https://www.example.com/api")!, + httpMethod: "GET" + ) + let interception = URLSessionTaskInterception(request: request, isFirstParty: true, trackingMode: .registeredDelegate) + interception.register(response: .mockResponseWith(statusCode: 200), error: nil) + interception.register( + metrics: .mockWith( + fetch: .init( + start: .mockDecember15th2019At10AMUTC(), + end: .mockDecember15th2019At10AMUTC(addingTimeInterval: 1) + ) + ) + ) + + // When + handler.interceptionDidComplete(interception: interception) + + // Then + waitForExpectations(timeout: 0.5, handler: nil) + XCTAssertNil(receivedHttpBody, "httpBody should be nil when the request has no body") + } + + func testGivenSpanCustomization_whenMultipleConcurrentRequestsComplete_itSafelyReadsPropertiesFromAllCallbacks() throws { + // This test simulates multiple URLSession tasks completing simultaneously on different background + // threads, each triggering `spanCustomization`. It verifies that reading `InterceptedRequest` + // properties is thread-safe: `url` and `httpMethod` are pre-captured value-type snapshots; + // `httpBody` is backed by immutable NSData, safe for concurrent reads. + let concurrentCount = 5 + let allSpansWritten = expectation(description: "All spans written") + allSpansWritten.expectedFulfillmentCount = concurrentCount + core.onEventWriteContext = { _ in allSpansWritten.fulfill() } + + var receivedUrls: [URL?] = Array(repeating: nil, count: concurrentCount) + var receivedMethods: [String?] = Array(repeating: nil, count: concurrentCount) + var receivedBodies: [Data?] = Array(repeating: nil, count: concurrentCount) + let lock = NSLock() + + let handler = TracingURLSessionHandler( + tracer: tracer, + contextReceiver: ContextMessageReceiver(), + samplingRate: .maxSampleRate, + firstPartyHosts: .init([ + "www.example.com": [.datadog] + ]), + traceContextInjection: .all, + telemetry: NOPTelemetry(), + spanCustomization: { request, _, _, _ in + // Read all InterceptedRequest properties — must be safe from any background thread + let url = request.url + let method = request.httpMethod + let body = request.httpBody + guard let index = Int(url?.lastPathComponent ?? "") else { return } + lock.lock() + receivedUrls[index] = url + receivedMethods[index] = method + receivedBodies[index] = body + lock.unlock() + } + ) + + // Given - prepare one interception per concurrent "task" + let interceptions: [URLSessionTaskInterception] = (0.. ImmutableRequest { var request = URLRequest(url: url) request.httpMethod = httpMethod + request.httpBody = httpBody request.allHTTPHeaderFields = allHTTPHeaderFields return ImmutableRequest(request: request) } diff --git a/api-surface-swift b/api-surface-swift index 1db0f9a785..2ee2077868 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -311,7 +311,11 @@ public enum Trace public var networkInfoEnabled: Bool public var eventMapper: EventMapper? public var customEndpoint: URL? - public typealias SpanCustomization = (ImmutableRequest, OTSpan, URLResponse?, Error?) -> Void + public typealias SpanCustomization = (InterceptedRequest, OTSpan, URLResponse?, Error?) -> Void + public struct InterceptedRequest + public let url: URL? + public let httpMethod: String? + public let httpBody: Data? public struct URLSessionTracking public var firstPartyHostsTracing: FirstPartyHostsTracing public var spanCustomization: SpanCustomization? From c43eacdb85fc902a946b8bb807decd9873098222 Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Fri, 27 Mar 2026 11:31:11 +0100 Subject: [PATCH 9/9] [RUM-1649] Fix lint: expand guard-else onto multiple lines --- DatadogTrace/Tests/TracingURLSessionHandlerTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift index e365c2004b..b9333f7555 100644 --- a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift +++ b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift @@ -1092,7 +1092,9 @@ class TracingURLSessionHandlerTests: XCTestCase { let url = request.url let method = request.httpMethod let body = request.httpBody - guard let index = Int(url?.lastPathComponent ?? "") else { return } + guard let index = Int(url?.lastPathComponent ?? "") else { + return + } lock.lock() receivedUrls[index] = url receivedMethods[index] = method