Chapter 12 — Session metrics collection
What URLSessionTaskMetrics gives you
flowchart LR
Fetch["fetchStart"] --> DNS["DNS lookup"] --> Connect["TCP connect"] --> TLS["TLS handshake"] --> ReqSent["request sent"] --> Wait["server processing (TTFB)"] --> Download["response download"] --> End["responseEnd"]
- DNS lookup — resolving the hostname to an IP. Slow here = DNS problems.
- TCP connect — establishing the connection. Slow = network latency/distance.
- TLS handshake — negotiating encryption. Only on a new connection; reused connections skip it.
- Time to first byte (TTFB) — from request sent to first response byte. This is mostly the server
- Download — receiving the body. Slow = big payload or slow link.
Collecting the metrics
// Chirp/Networking/MetricsCollector.swift
import Foundation
import os
struct RequestTiming: Sendable {
let url: String
let statusCode: Int?
let dns: TimeInterval?
let connect: TimeInterval?
let tls: TimeInterval?
let timeToFirstByte: TimeInterval?
let download: TimeInterval?
let total: TimeInterval?
let reusedConnection: Bool
let servedFromCache: Bool
let networkProtocol: String? // "h2", "h3", "http/1.1"
}
final class MetricsCollector: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let logger = Logger(subsystem: "com.chirp", category: "metrics")
let onTiming: @Sendable (RequestTiming) -> Void
init(onTiming: @escaping @Sendable (RequestTiming) -> Void) { self.onTiming = onTiming }
func urlSession(_ session: URLSession, task: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics) {
// Use the last transaction (after any redirects) for the outcome.
guard let t = metrics.transactionMetrics.last else { return }
func interval(_ start: Date?, _ end: Date?) -> TimeInterval? {
guard let start, let end else { return nil }
return end.timeIntervalSince(start)
}
let timing = RequestTiming(
url: t.request.url?.absoluteString ?? "",
statusCode: (t.response as? HTTPURLResponse)?.statusCode,
dns: interval(t.domainLookupStartDate, t.domainLookupEndDate),
connect: interval(t.connectStartDate, t.connectEndDate),
tls: interval(t.secureConnectionStartDate, t.secureConnectionEndDate),
timeToFirstByte: interval(t.requestEndDate, t.responseStartDate),
download: interval(t.responseStartDate, t.responseEndDate),
total: metrics.taskInterval.duration,
reusedConnection: t.isReusedConnection,
servedFromCache: t.resourceFetchType == .localCache,
networkProtocol: t.networkProtocolName)
onTiming(timing)
}
}
- You compute durations by subtracting the phase dates.
URLSessiongives you timestamps taskInterval.durationis the wall-clock total for the whole task — the number the user actually
Attaching the collector
let collector = MetricsCollector { timing in
Task { await MetricsStore.shared.record(timing) } // aggregate / forward
}
let (data, response) = try await session.data(for: request, delegate: collector)
Reading the numbers: what each pattern means
Aggregating and forwarding
actor MetricsStore {
static let shared = MetricsStore()
private var samples: [String: [TimeInterval]] = [:] // path → durations
func record(_ timing: RequestTiming) {
guard let total = timing.total, let path = URL(string: timing.url)?.path else { return }
samples[path, default: []].append(total)
// Flag anomalies locally, and/or forward to analytics/DataDog:
if total > 3.0 { /* slow request — attach breadcrumb, sample to Sentry */ }
}
func p95(forPath path: String) -> TimeInterval? {
guard let values = samples[path]?.sorted(), !values.isEmpty else { return nil }
return values[Int(Double(values.count) * 0.95)]
}
}
A note on overhead and privacy
- Metrics collection is cheap, but don't log every field of every request in production. Sample
- URLs can contain sensitive data. A path like
/users/<id>/profileembeds a user id; a query might
What we built
- Collected
URLSessionTaskMetricsvia aURLSessionTaskDelegate, turning phase timestamps into - Learned to read the breakdown — which phase dominates tells you who to fix (network vs connection
- Captured connection reuse, cache hits, and the HTTP protocol (h2/h3), and aggregated into
- Applied the same sampling and redaction discipline as observability.
Mental model to take away
URLSessionTaskMetricsbreaks a request into phases; you subtract the phase dates to get- Prefer connection reuse (one session, HTTP/2) to avoid repeated handshakes; confirm caching via
- Track p95 per endpoint, not averages — the tail is what users feel. Sample and redact before