Telemetry 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
Telemetry target inside the SwiftPM Package.
- It contains:
Call sites describe only "what happened"
telemetry.observe(.puzzleCompleted(id: puzzleId, 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 |
Subscribes via MXMetricManager.shared.add(self); on receiving MXMetricPayload, broadcasts to other sinks |
Performance / diagnostics persistence — MXMetricManagerSubscriber inherits NSObjectProtocol, so MetricKitSink must be an NSObject subclass, not a struct or actor. An NSObject subclass can conform to a Sendable sink protocol, but only while every stored property is immutable — a var there fails with "stored property … is mutable". Hold subscription state behind @MainActor or a Mutex, or mark the class @unchecked Sendable and synchronise it yourself |
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).
Wiring traps (hard-won — real project lessons)
A sink that exists as a type is worth zero until it is in the live sinks
array. Five traps, in the order they bit:
- Existing-but-unwired = dead code. Real-world example: a
GameCenterSink/AchievementEvaluator
were fully written but never added to the live Telemetry sinks list
(the composition root shipped [OSLogSink, NoOpTrackingSink] only) → no score, no
achievement, silently. Verify the composition root's actual sinks array,
not that the sink type compiles. git log -S "GameCenterSink(" showing only
the creation commit is the smoking gun.
- Sink ordering matters when one sink reads another's write. The facade
forwards in array order, so a sink that writes state another sink reads
must come first — e.g.
PersonalRecordSink writes completedCount before
GameCenterSink's evaluator reads it; reversed = an off-by-one where the
count achievement fires one completion late. Make read/write sink order
explicit and test it.
- I/O sinks on a gameplay-reachable path must not block. Completion events
are reached from the interactive path (e.g.
placeMove → sessionCompleted → telemetry.observe). A sink doing CloudKit reads + GameKit network I/O
synchronously there freezes the UI. Forward to such sinks on a
detached, order-preserving Task (chain each on the previous so events
still forward in order) and return immediately; keep the fast sinks
(OSLog / NoOp) synchronous.
- Late-binding to break the construction cycle. When a sink needs deps
(persistence, GameCenter) that themselves need
Telemetry, you cannot build
it at Telemetry-construction time. Wire a DeferredSink placeholder into the
facade at startup, then setDownstream([real sinks]) once (sync, from the
@MainActor composition root) after all deps are assembled. A final class … : Sendable (not an actor, and not @unchecked) backed by
Synchronization.Mutex<[any TelemetrySink]> (iOS 18+ / macOS 15+) keeps
setDownstream synchronous via downstream.withLock { $0 = sinks };
receive snapshots the sinks under withLock before any await.
- The sink firing ≠ the terminal call working. Tracing "wire 2 things"
uncovered a third gap: the GameKit terminal (
submitScore/reportAchievement)
was a stub that no-op'd / threw. Trace to the actual platform call
(GKLeaderboard.submitScore, GKAchievement.report), not just to the sink.
Terminal GameKit/StoreKit calls are device-gated — verify on a real device +
sandbox, never claim "done" from a green headless suite.
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
Telemetry target and use Logger directly. 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
Telemetry target is standalone; UI / Engine don't directly depend on anything beyond OSLog.
TelemetryEvent is a value type, Sendable.
- A default
NoOpTrackingSink is 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 concrete OSLogSink implementation dependency.
apple-three-piece-analytics: each piece corresponds to one sink.
swiftpm-modularization: why Telemetry is its own target.
1---2name: telemetry-facade-pattern3description: Single `Telemetry` SwiftPM target with a fan-out facade — callers say "what happened" (`telemetry.observe(event)`), facade dispatches to multiple sinks (OSLog / NoOp tracking / MetricKit / Game Center). Invoke when deciding logger / tracker coupling, designing telemetry interfaces, or when asked "should Logger and Tracking be one thing".4---56# Telemetry Facade Pattern78## When to invoke910- Starting a new project and designing the logger / tracker / metrics interface.11- About to introduce OSLog and any tracking / analytics at the same time.12- Wanting to preserve flexibility for "swap the tracking provider later".13- User asks "should Logger and Tracking be separate", "how should the event interface look".1415## Default decisions1617### A single `Telemetry` target1819- Create one `Telemetry` target inside the SwiftPM Package.20- It contains:21 - `TelemetryEvent` value type (enum / struct, `Sendable`)22 - `TelemetrySink` protocol:23 ```swift24 public protocol TelemetrySink: Sendable {25 func receive(_ event: TelemetryEvent) async26 }27 ```28 - The main facade — default to a `Telemetry` **actor**. Sink stateful subscriptions (e.g. `MetricKitSink` holding `MXMetricManagerSubscriber` reference identity) require an actor for clean lifecycle management. A `Sendable` struct facade is acceptable only when every sink is fully synchronous and stateless. The facade fans out to multiple sinks.29 - Default sinks (see below)3031### Call sites describe only "what happened"3233```swift34telemetry.observe(.puzzleCompleted(id: puzzleId, durationMs: 12_345))35```3637- The call site **doesn't know** who will consume the event.38- Swapping providers / adding sinks only requires replacing a sink; call sites change nothing.3940### Default sink set4142| Sink | Receives | Purpose |43|---|---|---|44| `OSLogSink` | All events | Human-readable debug messages |45| `TrackingSink` (default `NoOpTrackingSink`) | Business events | v1 has no third-party tracking but the protocol is reserved; future swaps require zero call-site changes |46| `MetricKitSink` | Subscribes via `MXMetricManager.shared.add(self)`; on receiving `MXMetricPayload`, broadcasts to other sinks | Performance / diagnostics persistence — `MXMetricManagerSubscriber` inherits `NSObjectProtocol`, so `MetricKitSink` must be an `NSObject` subclass, not a struct or actor. An `NSObject` subclass *can* conform to a `Sendable` sink protocol, but only while every stored property is immutable — a `var` there fails with "stored property … is mutable". Hold subscription state behind `@MainActor` or a `Mutex`, or mark the class `@unchecked Sendable` and synchronise it yourself |47| `GameCenterSink` (games) | Completion / achievement events | Submit score / unlock achievement |4849```swift50public struct NoOpTrackingSink: TelemetrySink {51 public init() {}52 public func receive(_ event: TelemetryEvent) async { /* intentionally empty */ }53}54```5556### Composition root wiring5758- The App target's DI composition root injects sinks into the facade.59- 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).6061#### Wiring traps (hard-won — real project lessons)6263A sink that *exists as a type* is worth **zero** until it is in the **live** sinks64array. Five traps, in the order they bit:65661. **Existing-but-unwired = dead code.** Real-world example: a `GameCenterSink`/`AchievementEvaluator`67 were fully written but never added to the live `Telemetry` sinks list68 (the composition root shipped `[OSLogSink, NoOpTrackingSink]` only) → no score, no69 achievement, silently. **Verify the composition root's actual sinks array**,70 not that the sink type compiles. `git log -S "GameCenterSink("` showing only71 the creation commit is the smoking gun.722. **Sink ordering matters when one sink reads another's write.** The facade73 forwards in array order, so a sink that *writes* state another sink *reads*74 must come first — e.g. `PersonalRecordSink` writes `completedCount` **before**75 `GameCenterSink`'s evaluator reads it; reversed = an off-by-one where the76 count achievement fires one completion late. Make read/write sink order77 explicit and test it.783. **I/O sinks on a gameplay-reachable path must not block.** Completion events79 are reached from the interactive path (e.g. `placeMove → sessionCompleted →80 telemetry.observe`). A sink doing CloudKit reads + GameKit network I/O81 synchronously there **freezes the UI**. Forward to such sinks on a82 **detached, order-preserving Task** (chain each on the previous so events83 still forward in order) and return immediately; keep the fast sinks84 (OSLog / NoOp) synchronous.854. **Late-binding to break the construction cycle.** When a sink needs deps86 (persistence, GameCenter) that themselves need `Telemetry`, you cannot build87 it at Telemetry-construction time. Wire a `DeferredSink` placeholder into the88 facade at startup, then `setDownstream([real sinks])` once (sync, from the89 `@MainActor` composition root) after all deps are assembled. A `final class90 … : Sendable` (not an actor, and not `@unchecked`) backed by91 `Synchronization.Mutex<[any TelemetrySink]>` (iOS 18+ / macOS 15+) keeps92 `setDownstream` synchronous via `downstream.withLock { $0 = sinks }`;93 `receive` snapshots the sinks under `withLock` before any `await`.945. **The sink firing ≠ the terminal call working.** Tracing "wire 2 things"95 uncovered a third gap: the GameKit terminal (`submitScore`/`reportAchievement`)96 was a stub that no-op'd / threw. **Trace to the actual platform call**97 (`GKLeaderboard.submitScore`, `GKAchievement.report`), not just to the sink.98 Terminal GameKit/StoreKit calls are device-gated — verify on a real device +99 sandbox, never claim "done" from a green headless suite.100101## Rationale102103- 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.104- OSLog + Tracking + MetricKit + GameCenter are all "event streams"; one unified interface is easier to maintain than four separate ones.105- Easy to test: inject a fake sink and assert on the event stream.106107## Deviation considerations108109- **Minimal App, OSLog only**: you can skip the `Telemetry` target and use `Logger` directly. But **if you anticipate adding tracking / metrics later**, building the facade up front pays off.110- **Need *routing* between sinks** (e.g. a MetricKit payload re-emitted into `TrackingSink`): handle routing inside the facade; call sites still unchanged.111- **Cross-platform** (Android / Linux): facade interface stays platform-neutral; sink implementations are per-platform.112113## Verification checklist114115- The `Telemetry` target is standalone; UI / Engine don't directly depend on anything beyond OSLog.116- `TelemetryEvent` is a value type, `Sendable`.117- A default `NoOpTrackingSink` is provided and wired in the composition root.118- Tests assert on event streams via fake sinks, not by parsing OSLog output.119- **The live composition root's sinks array actually contains every sink you120 intend to fire** (not just that the sink type exists) — the "existing-but-unwired" failure mode.121- Read/write-dependent sinks are ordered so writers precede readers, with a test122 pinning the order.123- I/O sinks on a gameplay-reachable completion path forward non-blocking; the124 interactive path is never frozen by a sink's CloudKit/GameKit work.125- The terminal platform call (GameKit/StoreKit) is reached and device-verified —126 not just the sink.127128## Related skills129130- `oslog-logger-defaults`: the concrete `OSLogSink` implementation dependency.131- `apple-three-piece-analytics`: each piece corresponds to one sink.132- `swiftpm-modularization`: why `Telemetry` is its own target.