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, notprint.Loggerintegrates 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
LoggingInterceptorusingos.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
URLSessioninstrumentation — real-user metrics and - Added an
X-Request-IDto 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.Loggerand 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