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. URLSession gives you timestamps
  • taskInterval.duration is 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>/profile embeds a user id; a query might

What we built

  • Collected URLSessionTaskMetrics via a URLSessionTaskDelegate, 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

  • URLSessionTaskMetrics breaks 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