3.8 KiB
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:
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:
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:
@moduledocon 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:
[: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.
# 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.