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/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 a073b280c2..8c236cb024 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,7 +56,8 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { samplingRate: SampleRate, firstPartyHosts: FirstPartyHosts, traceContextInjection: TraceContextInjection, - telemetry: Telemetry + telemetry: Telemetry, + spanCustomization: Trace.Configuration.SpanCustomization? = nil ) { self.tracer = tracer self.contextReceiver = contextReceiver @@ -62,6 +65,7 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { self.firstPartyHosts = firstPartyHosts self.traceContextInjection = traceContextInjection self.telemetry = telemetry + self.spanCustomization = spanCustomization } func modify(request: URLRequest, headerTypes: Set, networkContext: NetworkContext?) -> (URLRequest, TraceContext?, URLSessionHandlerCapturedState?) { @@ -315,6 +319,13 @@ internal struct TracingURLSessionHandler: DatadogURLSessionHandler { span.setTag(key: SpanTags.isBackground, value: didStartInBackground || doesEndInBackground) } + spanCustomization?( + .init(from: interception.request), + span, + resourceCompletion.httpResponse, + resourceCompletion.error + ) + span.finish(at: endTime) } 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/Trace.swift b/DatadogTrace/Sources/Trace.swift index da61aba8d7..9d77ba0d42 100644 --- a/DatadogTrace/Sources/Trace.swift +++ b/DatadogTrace/Sources/Trace.swift @@ -67,7 +67,8 @@ public enum Trace { samplingRate: configuration.debugSDK ? 100 : tracingSampleRate, firstPartyHosts: firstPartyHosts, traceContextInjection: traceContextInjection, - telemetry: core.telemetry + telemetry: core.telemetry, + spanCustomization: configuration.urlSessionTracking?.spanCustomization ) try core.register(urlSessionHandler: urlSessionHandler) diff --git a/DatadogTrace/Sources/TraceConfiguration.swift b/DatadogTrace/Sources/TraceConfiguration.swift index 443a780808..af829822e1 100644 --- a/DatadogTrace/Sources/TraceConfiguration.swift +++ b/DatadogTrace/Sources/TraceConfiguration.swift @@ -23,6 +23,28 @@ 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 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 GraphQL requests with the operation name: + /// ```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)") + /// } + /// } + /// ``` + /// + /// - Note: Keep the implementation fast and do not make any assumptions on the thread used to run it. + public typealias SpanCustomization = (InterceptedRequest, OTSpan, URLResponse?, Error?) -> 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 +116,11 @@ extension Trace { /// If your backend is also instrumented with Datadog, you will see the full trace (app → backend). public var firstPartyHostsTracing: FirstPartyHostsTracing + /// Optional callback to customize spans for intercepted network requests. + /// + /// - SeeAlso: ``SpanCustomization`` + 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 +150,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..b9333f7555 100644 --- a/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift +++ b/DatadogTrace/Tests/TracingURLSessionHandlerTests.swift @@ -851,6 +851,351 @@ class TracingURLSessionHandlerTests: XCTestCase { XCTAssertEqual(span.resource, "404", "404 responses should have resource set to '404'") } + // MARK: - Span Customization Tests + + func testGivenSpanCustomization_whenInterceptionCompletes_itCallsCustomizationWithAllParameters() throws { + let expectation = expectation(description: "Send span") + core.onEventWriteContext = { _ in expectation.fulfill() } + + var receivedRequest: Trace.Configuration.InterceptedRequest? + var receivedSpan: OTSpan? + var receivedResponse: URLResponse? + var receivedError: Error? + + let handler = TracingURLSessionHandler( + tracer: tracer, + contextReceiver: ContextMessageReceiver(), + samplingRate: .maxSampleRate, + firstPartyHosts: .init([ + "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") + } + ) + + // Given + let requestBody = #"{"operationName":"GetUser"}"#.data(using: .utf8) + let request: ImmutableRequest = .mockWith( + url: URL(string: "https://www.example.com/graphql")!, + httpMethod: "POST", + httpBody: requestBody + ) + 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") + 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) + XCTAssertNil(receivedError, "Error should be nil for successful requests") + + 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 { + 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, + telemetry: NOPTelemetry(), + spanCustomization: nil + ) + + // 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_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(), + samplingRate: .maxSampleRate, + firstPartyHosts: .init([ + "www.example.com": [.datadog] + ]), + traceContextInjection: .all, + telemetry: NOPTelemetry(), + spanCustomization: { _, span, response, error in + receivedResponse = response + receivedError = error + span.setTag(key: "custom.error.tag", value: "handled") + } + ) + + // Given + 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: nil, error: networkError) + 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(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) + XCTAssertTrue(span.isError) + 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 a515436b49..2ee2077868 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -311,12 +311,18 @@ public enum Trace public var networkInfoEnabled: Bool public var eventMapper: EventMapper? public var customEndpoint: URL? + 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? 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"