Files

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: @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:

[: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.