Add spanCustomization callback to Trace URLSession tracking#2741
Add spanCustomization callback to Trace URLSession tracking#2741mariusc83 wants to merge 9 commits into
Conversation
simaoseica-dd
left a comment
There was a problem hiding this comment.
Great addition. Thank you.
Left some comments. Also, please add an entry for this new feature on the changelog 👌
| XCTAssertFalse(span.isError) | ||
| } | ||
|
|
||
| func testGivenSpanCustomization_whenInterceptionCompletes_customTagsCoexistWithDefaultTags() throws { |
There was a problem hiding this comment.
Can you merge this test with the first one testGivenSpanCustomization_whenInterceptionCompletes_itCallsCustomizationWithAllParameters? They seem to have the same setup.
There was a problem hiding this comment.
Done in 805d67e — merged the custom-tags-coexist assertions into testGivenSpanCustomization_whenInterceptionCompletes_itCallsCustomizationWithAllParameters and replaced the third test with an error-only test (per the other comment).
| } | ||
|
|
||
| spanCustomization?( | ||
| interception.request.unsafeOriginal, |
There was a problem hiding this comment.
This is passing the request to a user provided closure. As the name implies, this is unsafe, since the user may touch the request at the same time the SDK is instrumenting it on a different thread. The API signature should be rethought to avoid the need of passing the request. As the name implies, this is unsafe, which in Swift language means it can lead to unpredictable state not detected by runtime, silently corrupting user data.
There was a problem hiding this comment.
Thanks for the flag — I investigated this in detail.
TL;DR: The concern is valid in general but does not apply here. The callback follows the same established pattern as RUM.ResourceAttributesProvider, which already ships and passes interception.request.unsafeOriginal in the exact same way (URLSessionRUMResourcesHandler.swift:140).
Why it is safe in this context:
-
The callback runs after the request completes — it is called inside
interceptionDidComplete, after all default tags are set and just beforespan.finish(). There is no concurrent SDK mutation of the request at this point. -
URLRequestis a Swift value type (struct with COW). When passed as the closure parameter, the user receives a copy. Mutating it in the callback cannot affect the SDK internal state or the original network request. -
The
unsafeOriginalnaming is about reading headers concurrently (see #1638, #1767) — specifically,URLRequest.allHTTPHeaderFieldscan crash when accessed concurrently. That risk exists during request interception, not in the completion callback where the request is already done.
Android SDK parity: The Android equivalent (TracedRequestListener.onRequestIntercepted) also passes the request to the callback. The newer NetworkTracedRequestListener wraps it in an immutable HttpRequestInfo interface, but the OkHttp-specific version passes the original okhttp3.Request directly (which is immutable by design in OkHttp). Both invoke the callback post-completion, same as here.
If we want to be extra safe, we could copy the relevant fields into a dedicated immutable struct (like Android HttpRequestInfo), but that would diverge from the existing RUM.ResourceAttributesProvider pattern and limit what users can inspect (e.g., httpBody for GraphQL operation extraction — the primary use case from #1649).
There was a problem hiding this comment.
I would reach to the person who wrote the original code with the "unsafeOriginal" to obtain more context on this. However:
- The fact we shipped something doesn't mean it's correct. It should not be relied upon as an absolute measure for correction.
- URLRequests are reusable, so nothing guarantees the user is not holding to a request. We cannot be sure the request is not being mutated (see below).
- "URLRequest is a Swift value type (struct with COW)" in theory. In practice
URLRequestis a toll-free bridge type toNSURLRequestandNSURLMutableRequest.A little research showsallHTTPHeaderFieldsmaps (or mapped on some recent OS version) to a bridgedNSMutableDictionary(https://discussions.unity.com/t/upgraded-to-0-7-1-allhttpheaderfields-crash/322436). We can also find additional context by @ncreated here (Crash accessingURLSessionTask.currentRequestandURLRequest.allHTTPHeaderFieldskean/Pulse#268). This means the actual implementation ofURLRequestmay not follow any of the Swift value type guarantees, and indeed evidence shows it doesn't. This has likely been fixed in the most recent OS release (26.*) since I tried to concurrently access this dictionary in a sample project and it didn't crash, but that is not good enough since we support OS releases all the way to iOS 12 right now. Which means for years to come we cannot assume this bug in the Apple frameworks was fixed. - It's irrelevant what Android is doing since the system frameworks are different between platforms and the bugs from one don't exist on the other.
Given all this, I am not in favor of shipping a callback like this without a very careful consideration of the impact. My suggestions:
- Does the use case this applies to really demands the URLRequest to be available on the callback? Can we pass certain fields of the request instead of the whole request?
- If it's determined we need the URLRequest, investigate in which OS versions this causes problems (like creating sample code that causes the crash and run it on the several simulators) and add a very visible warning in the documentation stating the problem, the OS versions affected, and asking users to mind this issue so they don't cause crashes when using this.
Overall, we should avoid shipping a feature that may cause crashes even if they are caused by a bug in the OS in a situation where we do know about the bug. If we have to, it needs to be thoroughly documented.
There was a problem hiding this comment.
This makes sense, but I have one question here: Shouldn't the fact that we only delegate the request object to a lambda after the request was completed (basically the interceptor doesn't touch it anymore) be enough for avoiding concurrency access issues ? I am asking as a non iOS expert. On top of that if we want to be 100% correct, using the ImmutableRequest copy from the original request as @ncreated proposes here: #1767 could cover the problem I think. What do you guys think ?
There was a problem hiding this comment.
After a discussion with @ncreated I have a better picture on how that COW approach works for the UrlRequest instance in iOS and if I understood correctly it is only copying the pointer and not the whole object. Meaning that yes you could experience issues but shouldn't the 1st argument in the LLM investigation: The callback runs after the request completes — it is called inside interceptionDidComplete, after all default tags are set and just before span.finish(). There is no concurrent SDK mutation of the request at this point. cover that issue already ? How likely it is that the system still touches the request after that ?
There was a problem hiding this comment.
Addressed in f0f1bdf — the SpanCustomization callback now receives ImmutableRequest instead of the raw URLRequest via unsafeOriginal. This avoids the thread-safety issues with URLRequest's bridged NSMutableDictionary internals on iOS 12–25.
ImmutableRequest captures .url, .httpMethod, and .ddOriginHeaderValue at interception time into plain let properties, so the callback gets a true value-type snapshot with no shared mutable state.
There was a problem hiding this comment.
☝️ Great insights, folks 👍. I agree that exposing an immutable copy of URLRequest makes the most sense here. That said, I’m not convinced that using ImmutableRequest as-is is the right approach, for a few reasons:
- This type only exposes three useful properties:
url,httpMethod, andddOriginHeaderValue. None of them seem especially useful for thespanCustomizationcallback use case we’re trying to enable, especially for GraphQL requests. I think the most valuable data for this callback isURLRequest.httpBody, which users can combine withurlandhttpMethodto decode the request and set span tags, similar to the example in the PR description. ImmutableRequeststill exposesunsafeOriginal, so users can run into the same issues as if we exposedURLRequestdirectly. The only difference is that it’s slightly less accessible.ImmutableRequestis an internal SDK type (defined inDatadogInternal), and we should avoid exposing it in a public interface because that would effectively make it part of the public API surface, with all the usual backwards-compatibility constraints that come with that.
My recommendation would be:
- leave
ImmutableRequestuntouched - define a separate
InterceptedRequest(or similarly named) type inDatadogTracewith only the properties needed for this functionality, likelyurl,httpMethod, andhttpBody
I think this would give us better interface segregation, more clarity for end users, and hopefully better thread safety.
There was a problem hiding this comment.
I agree exposing ImmutableRequest is not a good solution, since it exposes an entity that shouldn't be exposed, and does not solve the initial problem.
The problem here is not COW: the URLRequest in this situation should be safe to pass around according to how Swift is designed, and it would be if all its implementation had been done in Swift. COW is just an implementation detail that does not really matter in this context*. The problem here is the backing implementation is still done in ObjectiveC, and because the Swift type is bridged, Apple overlooked concurrency in some situations, making URLRequest behaviour inconsistent with how Swift works.
To solve this: I am not 100% against exposing the URLRequest, especially because the crasher was likely already solved by Apple on modern systems versions (needs confirmation). What I'm saying is, if we do choose to expose it, since we know the problem exists, we should document it thoroughly, including understanding which versions of the OS are affected by this, so people can take appropriate precautions. Otherwise we are exposing an API that we know might create crashers or other data corruption (not by our fault or our SDK users fault, but still, the problem exists).
- There is a myth about COW that should be clarified: COW does not happen automatically, in Swift all value types are copied when passed around. It just so happens Swift stdlib collections are really very small structs that hold a reference type, aka a class, with the real collection content, and implement COW manually. This implementation relies on isKnownUniquelyReferenced and must handle concurrent access internally if needed. This is all very well tested and established, and it works. Again, the problem here is the type we are interacting it is really a (poorly) bridged ObjectiveC type, and not a real Swift type, even if the bridging feature makes it seem so.
There was a problem hiding this comment.
Done in a546f9f. Created a new Trace.Configuration.InterceptedRequest struct in DatadogTrace with exactly the three properties you recommended: url, httpMethod, and httpBody.
Key points:
- No
unsafeOriginalexposure — users can't reach the underlyingURLRequest httpBodyis backed by immutableNSData, safe for concurrent reads (unlikeallHTTPHeaderFields)ImmutableRequestis unchanged and stays internal toDatadogInternal- The conversion happens in an internal
init(from: ImmutableRequest)onInterceptedRequest, invisible in the public API
Also added three new tests: nil body, GraphQL decoding end-to-end, and a concurrency stress test (5 tasks completing simultaneously on a concurrent queue) to validate thread safety of the callback.
There was a problem hiding this comment.
Addressed in a546f9f — introduced Trace.Configuration.InterceptedRequest with url, httpMethod, and httpBody. No unsafeOriginal is exposed. httpBody is NSData-backed (immutable), so the thread-safety concern specific to the bridged NSMutableDictionary in allHTTPHeaderFields does not apply.
| } | ||
|
|
||
| spanCustomization?( | ||
| interception.request.unsafeOriginal, |
There was a problem hiding this comment.
I would reach to the person who wrote the original code with the "unsafeOriginal" to obtain more context on this. However:
- The fact we shipped something doesn't mean it's correct. It should not be relied upon as an absolute measure for correction.
- URLRequests are reusable, so nothing guarantees the user is not holding to a request. We cannot be sure the request is not being mutated (see below).
- "URLRequest is a Swift value type (struct with COW)" in theory. In practice
URLRequestis a toll-free bridge type toNSURLRequestandNSURLMutableRequest.A little research showsallHTTPHeaderFieldsmaps (or mapped on some recent OS version) to a bridgedNSMutableDictionary(https://discussions.unity.com/t/upgraded-to-0-7-1-allhttpheaderfields-crash/322436). We can also find additional context by @ncreated here (Crash accessingURLSessionTask.currentRequestandURLRequest.allHTTPHeaderFieldskean/Pulse#268). This means the actual implementation ofURLRequestmay not follow any of the Swift value type guarantees, and indeed evidence shows it doesn't. This has likely been fixed in the most recent OS release (26.*) since I tried to concurrently access this dictionary in a sample project and it didn't crash, but that is not good enough since we support OS releases all the way to iOS 12 right now. Which means for years to come we cannot assume this bug in the Apple frameworks was fixed. - It's irrelevant what Android is doing since the system frameworks are different between platforms and the bugs from one don't exist on the other.
Given all this, I am not in favor of shipping a callback like this without a very careful consideration of the impact. My suggestions:
- Does the use case this applies to really demands the URLRequest to be available on the callback? Can we pass certain fields of the request instead of the whole request?
- If it's determined we need the URLRequest, investigate in which OS versions this causes problems (like creating sample code that causes the crash and run it on the several simulators) and add a very visible warning in the documentation stating the problem, the OS versions affected, and asking users to mind this issue so they don't cause crashes when using this.
Overall, we should avoid shipping a feature that may cause crashes even if they are caused by a bug in the OS in a situation where we do know about the bug. If we have to, it needs to be thoroughly documented.
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) <noreply@anthropic.com>
…rity 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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
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 <noreply@anthropic.com>
…stomization 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
a546f9f to
ae9e142
Compare
ncreated
left a comment
There was a problem hiding this comment.
Thanks for addressing the API concern. I left few minor comments, other than this - it looks good to me.
|
|
||
| # AI files | ||
| .claude/settings.local.json | ||
| .rum-analysis/ |
There was a problem hiding this comment.
nit/ Seems to be leftover from personal setup:
| .rum-analysis/ |
| // httpBody is Data? — safe to access; the thread-safety concern in ImmutableRequest | ||
| // applies only to allHTTPHeaderFields (bridged NSMutableDictionary). | ||
| self.httpBody = request.unsafeOriginal.httpBody |
There was a problem hiding this comment.
nit/ This comment seems to be agent's own reflection on this work, rather than sth useful in this context.
| // httpBody is Data? — safe to access; the thread-safety concern in ImmutableRequest | |
| // applies only to allHTTPHeaderFields (bridged NSMutableDictionary). | |
| self.httpBody = request.unsafeOriginal.httpBody | |
| self.httpBody = request.unsafeOriginal.httpBody |
simaoseica-dd
left a comment
There was a problem hiding this comment.
LGTM. Thank you for the contribution
What and why?
Adds a
spanCustomizationcallback toTrace.Configuration.URLSessionTracking, allowing users to customize spans for intercepted network requests — adding custom tags or overriding the operation name based on request/response content.This addresses a long-standing feature request (#1649, 7 reactions, 2+ years) for parity with the Android SDK's
TracedRequestListener. The key use case is tagging GraphQL requests with operation name/type, but it applies to any scenario where span metadata should depend on the request or response.How?
Trace.Configuration.SpanCustomizationtypealias:(URLRequest, OTSpan, URLResponse?, Error?) -> VoidspanCustomizationproperty toTrace.Configuration.URLSessionTrackingTracingURLSessionHandler.interceptionDidCompleteafter default tags are set and beforespan.finish(), so users can add custom tags or override the operation nameRUM.ResourceAttributesProviderpattern for API consistencyUsage:
Review checklist
make api-surfacewhen adding new APIsCloses #1649