telemetry-facade-pattern
Design the app-side event pipeline that fans one `telemetry.observe(event)` call out to logging, tracking, MetricKit and Game Center sinks. Use when deciding whether Logger and analytics tracking shar
Files3
View on GitHubSKILL.mdapple-dev-skills/skills/telemetry-facade-pattern/SKILL.md
GitHubTelemetry Facade Pattern
When to invoke
- Starting a new project and designing the logger / tracker / metrics interface.
- About to introduce OSLog and any tracking / analytics at the same time.
- Wanting to preserve flexibility for "swap the tracking provider later".
- User asks "should Logger and Tracking be separate", "how should the event interface look".
Default decisions
A single Telemetry target
- Create one
Telemetrytarget inside the SwiftPM Package. - It contains:
TelemetryEventvalue type (enum / struct,Sendable)TelemetrySinkprotocol:public protocol TelemetrySink: Sendable { func receive(_ event: TelemetryEvent) async }The main facade — choose the type per the table below. The facade fans out to multiple sinks.
Facade type Use when Cost actor Telemetry(default)any sink holds subscription identity (e.g. MXMetricManagerSubscriber) or does async I/Oobserveisasyncstruct Telemetry: Sendableevery sink is fully synchronous and stateless no lifecycle owner for stateful sinks Default sinks (see below)
Call sites describe only "what happened"
telemetry.observe(.sessionCompleted(id: sessionId, durationMs: 12_345))
- The call site doesn't know who will consume the event.
- Swapping providers / adding sinks only requires replacing a sink; call sites change nothing.
Default sink set
| Sink | Receives | Purpose |
|---|---|---|
OSLogSink |
All events | Human-readable debug messages |
TrackingSink (default NoOpTrackingSink) |
Business events | v1 has no third-party tracking but the protocol is reserved; future swaps require zero call-site changes |
MetricKitSink |
OS 26 and earlier: MXMetricManagerSubscriber (≤26). OS 27+: hold a single MetricManager() instance (27+) |
Performance / diagnostics persistence — base-class and actor-viability details: trap 6 in references/wiring-traps.md |
GameCenterSink (games) |
Completion / achievement events | Submit score / unlock achievement |
public struct NoOpTrackingSink: TelemetrySink {
public init() {}
public func receive(_ event: TelemetryEvent) async { /* intentionally empty */ }
}
Composition root wiring
- The App target's DI composition root injects sinks into the facade.
- Sinks are failure-isolated (one sink throwing or timing out must not stop the others) but not order-free: the facade forwards in array order, and a sink that reads state another sink writes must come after it (see trap 2).
For the six composition-root wiring traps (existing-but-unwired sinks, sink
ordering, blocking I/O on the gameplay path, late-binding, sink-fired vs
terminal-call-succeeded — GKLeaderboard.submitScore / GKAchievement.report
never reached — and MetricKitSink's OS-version-dependent base class), read
references/wiring-traps.md.
Rationale
- Decouples call sites from consumers: v1 can use
telemetry.observe(...)with no external tracking, and a future TelemetryDeck / in-house pipeline only swaps the sink. - OSLog + Tracking + MetricKit + GameCenter are all "event streams"; one unified interface is easier to maintain than four separate ones.
- Easy to test: inject a fake sink and assert on the event stream.
Deviation considerations
- Minimal App, OSLog only: you can skip the
Telemetrytarget and useLoggerdirectly. But if you anticipate adding tracking / metrics later, building the facade up front pays off. - Need routing between sinks (e.g. a MetricKit payload re-emitted into
TrackingSink): handle routing inside the facade; call sites still unchanged. - Cross-platform (Android / Linux): facade interface stays platform-neutral; sink implementations are per-platform.
Verification checklist
- The
Telemetrytarget is standalone; UI / Engine don't directly depend on anything beyond OSLog. TelemetryEventis a value type,Sendable.- A default
NoOpTrackingSinkis provided and wired in the composition root. - Tests assert on event streams via fake sinks, not by parsing OSLog output.
- The live composition root's sinks array actually contains every sink you intend to fire (not just that the sink type exists) — the "existing-but-unwired" failure mode.
- Read/write-dependent sinks are ordered so writers precede readers, with a test pinning the order.
- I/O sinks on a gameplay-reachable completion path forward non-blocking; the interactive path is never frozen by a sink's CloudKit/GameKit work.
- The terminal platform call (GameKit/StoreKit) is reached and device-verified — not just the sink.
Related skills
oslog-logger-defaults: the concreteOSLogSinkimplementation dependency.apple-three-piece-analytics: each piece corresponds to one sink.swiftpm-modularization: whyTelemetryis its own target.- Official sources: when verifying or updating a factual or version-sensitive claim, read
references/official-docs.md.