Repository navigation
Add per-transaction HTTP metrics - #15
Merged
o-nnerb merged 5 commits intoOct 2, 2026
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
HTTPClientTransactionMetricsis delivered per transaction, so one per redirect hop, in order, also for transactions that fail (phases that were never reached arenil,erroris 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.HTTPClientResponseDelegate.didCollectMetrics(task:_:)with a default implementation, so existing delegates are unaffected.HTTPClient.execute(_:timeout:logger:metrics:)andexecute(_:deadline:logger:metrics:)for the async API.HTTPClient.Configuration.collectDNSMetrics(defaultfalse), see below.How it works, and what to look at
HTTPExecutableRequestalready has, inRequestBagandTransaction.connectionAcquired(_:)is a new requirement with a default implementation, called byHTTP1Connection/HTTP2Connectionwhen they take the request.HTTPConnectionSetupRecorder, threaded throughHTTPConnectionPool.ConnectionFactory.collectDNSMetrics = truereplaces it withSystemDNSResolver, a copy of what it does (onegetaddrinfoper attempt, off the loop). On failure it asks SwiftNIO to resolve again so the error is SwiftNIO's ownUnknownHost..randomizedis always timed.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.HTTPRawByteCountingHandlerright behind the transport for HTTP/1,HTTPResponseBodyCountingHandlerafter 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 arenilfor HTTP/2.Things to know
HTTP2ClientTests). The Network.framework report and TLS metadata are therefore read without holding the connection back and applied when they arrive.as!of anil). 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.tlsCipherSuiteis Network.framework only: NIOSSL does not expose the negotiated cipher.ByteBufferon the HTTP/1 read and write path. I did not benchmark it.setupHTTP1Connectionnow 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 channelin 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 aContent-Lengththat the test server did not respect. The metrics tests do not use it. It is fixed in #16.Testing
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.testConnectTimeoutinHTTPClientTestsandAsyncAwaitEndToEndTests, which fail the same on a cleanreleasehere (the address used answers with a reset) and pass on Linux.-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(nolibFoundation.so), and the metrics tests on 6.1.3.swift format lint --strictis 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.