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