Skip to content

docs(URLSession): document the initialization cost and how to avoid it - #1172

Open
tomerhy wants to merge 4 commits into
open-telemetry:mainfrom
tomerhy:docs/urlsession-avoid-startup-scan
Open

tomerhy wants to merge 4 commits into
open-telemetry:mainfrom
tomerhy:docs/urlsession-avoid-startup-scan

Conversation

@tomerhy

@tomerhy tomerhy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Docs only.

Creating URLSessionInstrumentation searches every class loaded in the process to find session delegates. There's already a way to skip that — pass delegateClassesToInstrument — but neither that option nor ignoredClassPrefixes appears in the README, so the only discoverable behaviour is the expensive default.

This documents both options and adds a short section on what the search costs and how to avoid it.

The reason I went looking: on #895 @williazz reports the instrumentation blocking app launch by ~500 ms across devices and simulators, and says the only workaround they found was deferring initialization, which then drops early requests. The escape hatch they needed already exists in the API, it just isn't written down anywhere.

I've tried to be straight about the trade-off rather than just recommending it — if you pass an explicit list, a delegate you leave out isn't instrumented and its requests aren't captured. And I noted that deferring initialization is not an equivalent workaround, for the reason williazz hit.

No code changes. The snippet compiles (checked against the current initializer).

🤖 Generated with Claude Code

delegateClassesToInstrument and ignoredClassPrefixes were not documented at all,
so the only discoverable behaviour was the default, which searches every class
loaded in the process. Documents both, and adds a section on what that search
costs and how passing the delegate classes explicitly skips it.
@williazz

Copy link
Copy Markdown

Did you reproduce the bug by any chance?

@tomerhy

tomerhy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

@williazz yeah, I measured it. In a test process with ~35k classes loaded, walking every class's method list takes ~285 ms (3 runs: 285/282/290) and finds about 19 matching classes. objc_getClassList itself is only ~5-15 ms, so it's the per-class method walk that costs, and it scales with how many classes your app links rather than with anything URLSession-related. Your ~500 ms on a real app with more frameworks linked lines up with that.

Two caveats on my numbers: I timed the enumeration and method walk in isolation rather than the shipped init (which also swizzles the matches), and the match count was collected without synchronization so treat it as approximate.

Passing delegateClassesToInstrument skips the search entirely, which is what this PR documents, so that should unblock you now — with the trade-off that a delegate you don't list isn't instrumented.

Longer term I think the answer is discovering delegate classes on demand instead of scanning everything up front. I'll open an issue for that so the performance side is tracked on its own rather than buried in a docs PR.

@tomerhy

tomerhy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Sharing how I measured, since my number came from timing the scan in isolation rather than the shipped code path.

If you want the number for your own app, this measures the real init:

var classCount: UInt32 = 0
_ = objc_copyClassList(&classCount)

let t0 = ProcessInfo.processInfo.systemUptime
let instrumentation = URLSessionInstrumentation(configuration: URLSessionInstrumentationConfiguration())
let ms = (ProcessInfo.processInfo.systemUptime - t0) * 1000
print("classes: \(classCount), URLSessionInstrumentation init: \(ms) ms")

One warning from my own mistake: don't time a second init to compare against delegateClassesToInstrument. Since #1157 the swizzling is installed once per process, so the second call returns immediately and you measure ~0 ms and conclude the explicit list is free when you've really measured nothing. Comparing the two paths needs separate launches.

And this is the harness behind the ~285 ms, so the caveat is visible rather than just asserted — it reimplements what injectInNSURLClasses does, minus the swizzling:

let selectors = [
  #selector(URLSessionDataDelegate.urlSession(_:dataTask:didReceive:)),
  #selector(URLSessionDataDelegate.urlSession(_:dataTask:didReceive:completionHandler:)),
  #selector(URLSessionDataDelegate.urlSession(_:task:didCompleteWithError:)),
  #selector(URLSessionTaskDelegate.urlSession(_:task:didFinishCollecting:))
]

let t0 = ProcessInfo.processInfo.systemUptime
let classes = InstrumentationUtils.objc_getClassList()
let listed = ProcessInfo.processInfo.systemUptime

DispatchQueue.concurrentPerform(iterations: classes.count) { i in
  var methodCount: UInt32 = 0
  guard let methodList = class_copyMethodList(classes[i], &methodCount) else { return }
  defer { free(methodList) }
  for j in 0 ..< Int(methodCount) where selectors.contains(method_getName(methodList[j])) { break }
}
let done = ProcessInfo.processInfo.systemUptime

print("getClassList \((listed - t0) * 1000) ms, method walk \((done - listed) * 1000) ms")

On my machine, 35,417 classes: getClassList ~5-15 ms, method walk 270-285 ms, over three runs. Worth noting it uses four of the six selectors the real code checks, so if anything it understates the walk slightly.


`delegateClassesToInstrument: [AnyClass]?`: The session delegate classes to instrument. When this is `nil`, the instrumentation discovers them by examining **every class loaded in the process** at initialization, which is the default. Passing your delegate classes explicitly skips that search — see [Initialization cost](#initialization-cost) below.

`ignoredClassPrefixes: [String]?`: Class name prefixes to leave out of that search.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove this claim for now? injectInNSURLClasses() calls objc_getClassList() directly, and ignoredClassPrefixes is never read after configuration, so setting it does not exclude classes or reduce the initialization scan.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, removed. Confirmed it's assigned in the initializer and never read anywhere — the search uses the hardcoded excludeList instead, so setting ignoredClassPrefixes has no effect at all. I shouldn't have documented it without checking it was wired up.

Worth deciding separately whether to wire it up or deprecate it, since right now it's public API that silently does nothing. Happy to open an issue for that if useful.

The option is stored but never read, so documenting it as excluding classes from the
delegate search would be describing behaviour that does not exist.
)
```

Only the classes you list are instrumented, so a delegate you leave out is not, and requests made through it are not captured.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small wording fix: completion-handler requests are still captured even when their session's delegate is omitted. I verified this with a local HTTP request: both span creation and completion ran. Would it be clearer to say that callbacks on unlisted delegates are not instrumented?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aranhave You're right, reworded. The completion-handler path is swizzled on URLSession itself (injectIntoNSURLSessionCreateTaskWithParameterMethods), not through the delegate, so those requests are captured regardless of what's in the list. One thing I noticed while checking - if you omit a delegate you actually use, the span still starts at task creation but nothing ends it, since didCompleteWithError is only swizzled on listed classes - so I added a line warning about that too.

Completion-handler and async/await requests are instrumented on URLSession
itself, not through the session delegate, so they are captured whether or not
their delegate class is listed. The previous wording claimed all requests
through an omitted delegate are lost.

Also warn about the other direction: a delegate-driven request through an
omitted class still starts a span at task creation, but the callback that ends
it is only swizzled on listed classes, so that span is never ended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linux-foundation-easycla

Copy link
Copy Markdown

CLA Missing ID

One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via:

Co-authored-by: name <email>

Supported Co-authored-by: formats include:

  1. Anything <id+login@users.noreply.github.com> - it will locate your GitHub user by id part.
  2. Anything <login@users.noreply.github.com> - it will locate your GitHub user by login part.
  3. Anything <public-email> - it will locate your GitHub user by public-email part. Note that this email must be made public on Github.
  4. Anything <other-email> - it will locate your GitHub user by other-email part but only if that email was used before for any other CLA as a main commit author.
  5. login <any-valid-email> - it will locate your GitHub user by login part, note that login part must be at least 3 characters long.

Alternatively, if the co-author should not be included, remove the Co-authored-by: line from the commit message.

Please update your commit message(s) by doing git commit --amend and then git push [--force] and then request re-running CLA check via commenting on this pull request:

/easycla

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.45%. Comparing base (7bad8ae) to head (376744d).
⚠️ Report is 402 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##             main    #1172       +/-   ##
===========================================
+ Coverage   67.89%   79.45%   +11.56%     
===========================================
  Files         344       95      -249     
  Lines       15169     7239     -7930     
===========================================
- Hits        10299     5752     -4547     
+ Misses       4870     1487     -3383     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants