Files
elixir-patterns/patterns/telemetry.md
T

117 lines
3.8 KiB
Markdown

# 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.