patterns: add telemetry emission patterns (dedicated submodule, naming, measurements vs metadata)
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user