Telemetry facade pattern
Skill wei18/apple-dev-skills/apple-dev-skills/skills/telemetry-facade-pattern
Reusable Claude Code skills for AI-agent-driven Swift / Apple-platform development — composable via git submodule; aggregates other specialist skill repos
npx -y skills add wei18/apple-dev-skills --skill telemetry-facade-patternAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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 starting a new project that will log + track, deciding logger / tracker coupling, designing telemetry interfaces, or when asked "should Logger and Tracking be one thing".
SKILL.md
7.0 KB, ~1.6k tokens by cl100k_base, as published. Nobody here has run it
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
Telemetrytarget inside the SwiftPM Package. - It contains:
TelemetryEventvalue type (enum / struct,Sendable)TelemetrySinkprotocol- The main facade — default to a
Telemetryactor. Sink stateful subscriptions (e.g.MetricKitSinkholdingMXMetricManagerSubscriberreference identity) require an actor for clean lifecycle management. ASendablestruct facade is acceptable only when every sink is fully synchronous and stateless. The facade fans out to multiple sinks. - Default sinks (see below)
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 |
GameCenterSink (games) | Completion / achievement events | Submit score / unlock achievement |
public struct NoOpTrackingSink: TelemetrySink {
public init() {}
public func receive(_ event: TelemetryEvent) { /* intentionally empty */ }
}
Composition root wiring
- The App target's DI composition root injects sinks into the facade.
- Sinks are independent; one sink's failure does not affect the others.
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. Four traps, in the order they bit:
- Existing-but-unwired = dead code. Real-world example: a
GameCenterSink/AchievementEvaluatorwere fully written but never added to the liveTelemetrysinks 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.
PersonalRecordSinkwritescompletedCountbeforeGameCenterSink'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 aDeferredSinkplaceholder into the facade at startup, thensetDownstream([real sinks])once (sync, from the@MainActorcomposition root) after all deps are assembled.final class @unchecked Sendable+NSLock(not an actor) keepssetDownstreamsynchronous;receivesnapshots state under the lock before anyawait. - 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
Telemetrytarget and useLoggerdirectly. But if you anticipate adding tracking / metrics later, building the facade up front pays off. - Need inter-sink dependencies (e.g.
MetricKitSinkpayloads must go throughTrackingSinkfirst): 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.
Gives 0 of the 12 instructions most monitoring observability skills give in ~1.6k tokens
Counted across 481 of the 483 authors here whose files we hold, read 2026-08-06
- link every alert to a runbookin 43 of 481, across 35 files
- use structured json loggingin 36 of 481, across 31 files
- alert on user-facing symptomsin 20 of 481, across 15 files
- emit structured JSON logs with stable event namesin 18 of 481, across 13 files
- propagate trace context across boundariesin 16 of 481
- use histograms for latency trackingin 14 of 481, across 9 files
- use OpenTelemetry for distributed tracingin 13 of 481, across 8 files
- include a correlation ID on every log linein 13 of 481, across 8 files
- Define service level objectivesin 10 of 481, across 7 files
- Call useAzureMonitor before importing other modulesin 9 of 481, across 2 files
- stop and ask for clarification if inputs are missingin 9 of 481, across 2 files
- define on-call questions before adding telemetryin 9 of 481, across 4 files
Said here and by no other author read
- create one Telemetry target
- use an actor for the main facade
- describe only what happened at call sites
- default to a NoOp tracking sink
- inject sinks at the composition root
- isolate sinks from each other
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.