Chapter 11 — Network logging & observability with Sentry and DataDog

The three pillars of observability

  • Logs — timestamped records of events: "POST /posts → 201 in 240ms." Great for reconstructing what
  • Metrics — aggregated numbers over time: request rate, error rate, p95 latency. Great for spotting
  • Traces — the timeline of a single operation across systems: this request spent 40ms in DNS, 80ms in

Developer logging: a logging interceptor

// Chirp/Networking/LoggingInterceptor.swift
import Foundation
import os

struct LoggingInterceptor: RequestInterceptor {
    private let logger = Logger(subsystem: "com.chirp", category: "network")

    func adapt(_ request: URLRequest) async throws -> URLRequest {
        logger.debug("→ \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")")
        if let body = request.httpBody, let json = String(data: redact(body), encoding: .utf8) {
            logger.debug("  body: \(json)")
        }
        return request
    }

    func retry(_ request: URLRequest, response: HTTPURLResponse?, data: Data?,
               error: Error?, attempt: Int) async throws -> RetryResult {
        if let response {
            let symbol = (200..<300).contains(response.statusCode) ? "✓" : "✗"
            logger.debug("← \(symbol) \(response.statusCode) \(request.url?.path ?? "")")
        } else if let error {
            logger.error("← ✗ \(request.url?.path ?? "")\(error.localizedDescription)")
        }
        return .doNotRetry            // logging never changes control flow
    }
}
  • Use os.Logger, not print. Logger integrates with the system logging (viewable in Console.app
  • It logs but never controls. The interceptor returns .doNotRetry — logging observes, it doesn't

The rule that will save you: redact secrets

private func redact(_ data: Data) -> Data {
    guard var string = String(data: data, encoding: .utf8) else { return data }
    // Blank out sensitive JSON fields before they ever reach a log.
    for field in ["password", "accessToken", "refreshToken", "token"] {
        string = string.replacingOccurrences(
            of: "\"\(field)\"\\s*:\\s*\"[^\"]*\"",
            with: "\"\(field)\":\"***\"", options: .regularExpression)
    }
    return Data(string.utf8)
}

Sentry: catching what breaks

// in the App's init / configuration
import Sentry

SentrySDK.start { options in
    options.dsn = Config.sentryDSN                 // your project's key, from config not source
    options.tracesSampleRate = 0.2                 // sample 20% of requests for performance traces
    options.enableNetworkBreadcrumbs = true        // auto-record each URLSession request as a breadcrumb
    options.enableNetworkTracking = true           // auto-create performance spans for requests
    #if DEBUG
    options.debug = true
    #endif
}
// in APIClient, when a request ultimately fails:
func report(_ error: APIError, endpointPath: String, status: Int?) {
    SentrySDK.capture(error: error) { scope in
        scope.setContext(value: ["path": endpointPath, "status": status ?? -1], key: "request")
        scope.setLevel(status == 500 ? .error : .warning)
    }
}

// and set who the user is, so failures are attributable:
SentrySDK.configureScope { scope in
    scope.setUser(User(userId: currentUserID.uuidString))
}

DataDog: metrics, logs, and traces

import DatadogCore
import DatadogRUM

Datadog.initialize(
    with: Datadog.Configuration(clientToken: Config.ddClientToken, env: "production"),
    trackingConsent: .granted)                     // respect user privacy consent

RUM.enable(with: RUM.Configuration(applicationID: Config.ddAppID))

// Auto-instrument URLSession: DataDog tracks every request's timing & outcome as a RUM resource.
URLSessionInstrumentation.enable(
    with: .init(delegateClass: ChirpSessionDelegate.self))

// Build the app's session with an instrumented delegate:
let session = URLSession(configuration: config,
                         delegate: ChirpSessionDelegate(),   // conforms to DataDog's tracking delegate
                         delegateQueue: nil)

Correlating client and server: request IDs

struct RequestIDInterceptor: RequestInterceptor {
    func adapt(_ request: URLRequest) async throws -> URLRequest {
        var request = request
        request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-ID")
        return request
    }
}
flowchart TB Req["Request (+ X-Request-ID)"] --> Log["LoggingInterceptor (console, redacted)"] Req --> Sentry["Sentry: breadcrumb + span; capture on failure"] Req --> DD["DataDog: RUM resource (timing, outcome, metrics)"] Req --> Server["Server logs the same request id"] Sentry --> Dash["errors grouped, by user"] DD --> Dash2["dashboards: rate, error %, p95 latency"]

What we built

  • Built a LoggingInterceptor using os.Logger (privacy-aware) that records every request/
  • Made redaction a rule: never log Authorization, passwords, or tokens; prefer an allowlist of
  • Integrated Sentry for error/crash tracking with automatic network breadcrumbs/spans, plus
  • Integrated DataDog RUM for automatic URLSession instrumentation — real-user metrics and
  • Added an X-Request-ID to correlate an app failure with Sentry, DataDog, and the server log.

Mental model to take away

  • Observability = logs + metrics + traces; networking produces all three, and you need them in
  • Redact secrets everywhere you log; use os.Logger and allowlist safe fields.
  • Sentry = errors/crashes grouped and attributed; DataDog RUM = automatic network metrics/traces
  • A client-generated request id ties the app error, the observability tools, and the server log into