From b833d05410adae3b92a4dc77855774b9d0f56521 Mon Sep 17 00:00:00 2001 From: Rodin Date: Thu, 7 May 2026 19:28:48 -0700 Subject: [PATCH] patterns: add telemetry emission patterns (dedicated submodule, naming, measurements vs metadata) --- patterns/telemetry.md | 116 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 patterns/telemetry.md diff --git a/patterns/telemetry.md b/patterns/telemetry.md new file mode 100644 index 0000000..31785b0 --- /dev/null +++ b/patterns/telemetry.md @@ -0,0 +1,116 @@ +# Telemetry Patterns + +## Pattern 1: Dedicated Telemetry Submodule + +**When to use:** Any module that emits 2+ telemetry events, especially GenServers and pipeline stages. + +**What it does:** Extracts all `:telemetry.execute/3` calls into a sibling `Telemetry` module. The parent module calls into the telemetry module; the telemetry module owns event names, measurements shape, and metadata contracts. + +**Structure:** +``` +lib/my_app/quote_feed.ex # GenServer — state + logic +lib/my_app/quote_feed/telemetry.ex # Telemetry — event emission + docs +``` + +**Example:** +```elixir +defmodule MyApp.QuoteFeed.Telemetry do + @moduledoc """ + Telemetry events for QuoteFeed. + + ## Events + + * `[:my_app, :quote_feed, :connected]` — WebSocket connected. + Measurements: `%{system_time: integer()}`. + Metadata: `%{url: String.t()}`. + + * `[:my_app, :quote_feed, :tick, :received]` — Price tick received. + Measurements: `%{latency_ms: integer()}`. + Metadata: `%{symbol: String.t(), price: Decimal.t()}`. + """ + + @doc "Emit a connection event." + def connected(url) do + :telemetry.execute( + [:my_app, :quote_feed, :connected], + %{system_time: System.system_time()}, + %{url: url} + ) + end + + @doc "Emit a tick received event." + def tick_received(symbol, price, latency_ms) do + :telemetry.execute( + [:my_app, :quote_feed, :tick, :received], + %{latency_ms: latency_ms}, + %{symbol: symbol, price: price} + ) + end +end +``` + +**Caller side:** +```elixir +defmodule MyApp.QuoteFeed do + alias MyApp.QuoteFeed.Telemetry + + def handle_info({:connected, url}, state) do + Telemetry.connected(url) + {:noreply, %{state | connected: true}} + end +end +``` + +**Why this over inline:** +- **Discoverability:** One place lists all events a component emits. Handlers/dashboards have a single source of truth. +- **Cohesion:** GenServer focuses on state management. Telemetry module focuses on observability contracts. +- **Documentation:** `@moduledoc` on the telemetry module becomes the event catalog. No hunting through callbacks. +- **Testability:** You can assert on telemetry calls without coupling to GenServer internals. + +**When NOT to use:** A module with exactly one telemetry call (e.g., a simple function wrapping a single `:telemetry.execute`). Inline is fine there — the dedicated module adds ceremony without benefit. + +--- + +## Pattern 2: Event Naming Convention + +**What it does:** Event names follow a hierarchical path: `[app, context, noun, verb_past_tense]`. + +**Examples:** +```elixir +[:gargoyle, :engine, :aggregation, :group, :completed] +[:gargoyle, :market_data, :quote_feed, :connected] +[:gargoyle, :daily_pnl, :snapshot, :backfilled] +``` + +**Rules:** +- App prefix first (namespace isolation) +- Bounded context second (matches code structure) +- Noun before verb (what happened to what) +- Past tense verbs (events are facts — they already happened) +- Never use generic names like `[:my_app, :event]` or `[:my_app, :metric]` + +**Why:** Consistent naming lets you attach handlers by prefix (`[:gargoyle, :engine | _]`) and build dashboards without memorizing arbitrary event names. + +--- + +## Pattern 3: Measurements vs Metadata Separation + +**What it does:** Measurements are numeric values you aggregate (sum, avg, p99). Metadata is context for filtering/grouping. + +```elixir +# Good: clear separation +:telemetry.execute( + [:my_app, :request, :completed], + %{duration_ms: 42, response_bytes: 1024}, # measurements: numbers + %{method: :get, path: "/api/v1/users", status: 200} # metadata: dimensions +) + +# Bad: mixing concerns +:telemetry.execute( + [:my_app, :request, :completed], + %{duration_ms: 42, method: :get}, # method isn't a measurement + %{response_bytes: 1024, path: "/api"} # bytes should be a measurement +) +``` + +**Rule of thumb:** If you'd put it on a Y-axis in a graph → measurement. If you'd use it as a filter/group-by → metadata.