Skip to content

Add per-transaction HTTP metrics - #15

Merged
o-nnerb merged 5 commits into
releasefrom
claude/async-http-client-metrics-analysis-65234f
Oct 2, 2026
Merged

o-nnerb merged 5 commits into
releasefrom
claude/async-http-client-metrics-analysis-65234f

Conversation

@o-nnerb

@o-nnerb o-nnerb commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

What

Adds per-transaction metrics to the client: timings of every phase of a request, information about the connection it ran on, how the connection was established, and byte counts. It is meant to back a URLSessionTaskMetrics-like API in callers (request-dl).

One HTTPClientTransactionMetrics is delivered per transaction, so one per redirect hop, in order, also for transactions that fail (phases that were never reached are nil, error is set).

API

Everything is additive.

  • HTTPClientTransactionMetrics (public struct) with dates for: fetch start, queued, request start/end, response start/end.
  • HTTPClientTransactionMetrics.Connection: id, h1/h2, isReused, local/remote address, isProxyConnection, tlsVersion, tlsCipherSuite, and the DNS lookup / connect / secure connection dates. The establishment dates are only reported by the first transaction on a connection.
  • Bytes: request header/body sent (on the wire and before encoding), response header/body received (before decompression) and the body after decompression.
  • Delivery:
    • HTTPClientResponseDelegate.didCollectMetrics(task:_:) with a default implementation, so existing delegates are unaffected.
    • HTTPClient.execute(_:timeout:logger:metrics:) and execute(_:deadline:logger:metrics:) for the async API.
  • HTTPClient.Configuration.collectDNSMetrics (default false), see below.

How it works, and what to look at

  • Request phases come from the callbacks HTTPExecutableRequest already has, in RequestBag and Transaction. connectionAcquired(_:) is a new requirement with a default implementation, called by HTTP1Connection / HTTP2Connection when they take the request.
  • Connection setup is recorded per connection by HTTPConnectionSetupRecorder, threaded through HTTPConnectionPool.ConnectionFactory.
    • POSIX: DNS is timed by wrapping the resolver. SwiftNIO's default resolver is not available to wrap, so collectDNSMetrics = true replaces it with SystemDNSResolver, a copy of what it does (one getaddrinfo per attempt, off the loop). On failure it asks SwiftNIO to resolve again so the error is SwiftNIO's own UnknownHost. .randomized is always timed.
    • Network.framework: the phases are derived from NWConnection.EstablishmentReport, which only has durations in whole milliseconds. They are laid out one after the other, so those dates are as exact as the report.
    • Proxies: connecting includes setting up the tunnel; TLS to the target follows it.
  • Bytes are counted by handlers in the pipeline (HTTPRawByteCountingHandler right behind the transport for HTTP/1, HTTPResponseBodyCountingHandler after the decoder for HTTP/1 and for HTTP/2 streams). For HTTP/1 a transaction measures what is its own against snapshots, which is exact because a connection carries one at a time. Header sizes are nil for HTTP/2.

Things to know

  • Connection hand-over must not wait for anything. Putting an asynchronous step between a connection being established and it being handed over makes HTTP/2 connections hang (reproduced with HTTP2ClientTests). The Network.framework report and TLS metadata are therefore read without holding the connection back and applied when they arrive.
  • Asking the Network framework for TLS metadata of a connection that has none crashes NIOTS (as! of a nil). With a proxy the TLS is done by NIOSSL over such a connection, so the metadata is only requested when there is no NIOSSL handler. There is a test that crashes without that.
  • tlsCipherSuite is Network.framework only: NIOSSL does not expose the negotiated cipher.
  • The raw byte counter takes a lock per ByteBuffer on the HTTP/1 read and write path. I did not benchmark it.
  • The existing test helper setupHTTP1Connection now also removes the raw byte counter, as it does the encoder and decoder it sits next to.

A failure that looked like a client bug, and was not

While working on this I saw redirects on the async API fail the next hop with I/O on closed channel in 15 to 20 percent of the tries. I first took it for a race in the client. It was the test endpoint /redirect/302-with-body, which announced a Content-Length that the test server did not respect. The metrics tests do not use it. It is fixed in #16.

Testing

  • New tests: 56 in HTTPClientMetricsTests.swift. They cover the recorders, the resolvers (including the failure path), HTTP/1, HTTP/2, TLS, proxy, redirects, delegate and async delivery, byte counts, compression, and Network.framework. The header byte counts are checked against a raw TCP server that counts what it receives.
  • macOS: full suite, 669 tests. The only failures are testConnectTimeout in HTTPClientTests and AsyncAwaitEndToEndTests, which fail the same on a clean release here (the address used answers with a reset) and pass on Linux.
  • Linux (Apple container): full suite on Swift 6.2, 660 tests, no failures. Build with the CI flags (-Xswiftc -warnings-as-errors --explicit-target-dependency-import-check error) on Swift 6.1.3 and 6.2, release build, scripts/run-linkage-test.sh (no libFoundation.so), and the metrics tests on 6.1.3.
  • swift format lint --strict is clean, and the DocC build shows no warnings.

I have not run the nightly Swift toolchains, the static SDK build or the C++ interop job.

o-nnerb and others added 4 commits October 2, 2026 06:49
…ion info)

Record when a request was queued, started, finished sending, and when the
response head and end arrived, plus which connection it ran on (id, h1/h2,
reuse, local/remote address). One HTTPClientTransactionMetrics is delivered
per transaction, i.e. per redirect hop, through the new
HTTPClientResponseDelegate.didCollectMetrics and through new
HTTPClient.execute(_:timeout:logger:metrics:) overloads on the async API.

Connection setup phases (DNS, connect, TLS) are not covered yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The first transaction on a new connection now reports how the connection was
established: when the DNS lookup, connecting and the TLS handshake started and
ended, and whether it goes through a proxy. Reused connections, and
connections that existed before the request started, report none of it.

- NIOPosix: DNS is timed by wrapping the resolver. SwiftNIO's default resolver
  can't be wrapped, so HTTPClient.Configuration.collectDNSMetrics replaces it
  with a copy that resolves the same way (errors still come from SwiftNIO).
  The randomized resolver is always timed.
- Network.framework: the phases are derived from NWConnection's establishment
  report, which only has durations. The report is read without holding back
  the connection, because delaying the hand-over hangs HTTP/2 connections.
- Proxies: connecting includes setting up the tunnel, TLS to the target follows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Every transaction now reports how many bytes it moved: the request head and
body that were sent, the response head and body that were received, and the
body as the caller gets it after decompression. For HTTP/1 they are counted on
the connection, which carries one transaction at a time; header sizes are not
known for HTTP/2, which compresses the headers and shares the connection.

The negotiated TLS version is reported for every transaction on a connection,
and so is the cipher suite where the platform tells it. NIOSSL does not, so
that is Network.framework only.

The Network.framework queries run without holding the connection back, and are
skipped when NIOSSL does the TLS: asking NIOTS for TLS metadata of a connection
without TLS crashes it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Linux builds of the CI treat warnings as errors and reject sharing the
responder through a @sendable closure. Create one per connection and share
the counter through a lock instead.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@o-nnerb o-nnerb added the 🆕 semver/minor Additive, non-breaking API change label Oct 2, 2026
@o-nnerb
o-nnerb merged commit 45db6fe into release Oct 2, 2026
37 checks passed
@o-nnerb
o-nnerb deleted the claude/async-http-client-metrics-analysis-65234f branch October 2, 2026 16:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🆕 semver/minor Additive, non-breaking API change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant