Skip to content
Development
Skill

/telemetry-essentials

Use when adding logging, metrics, or instrumentation — Logger metadata, :telemetry handlers, Telemetry.Metrics tags, Ecto/Phoenix events, LiveDashboard, OpenTelemetry. Not for deployment config (use deployment-gotchas).

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill telemetry-essentials --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/telemetry-essentials

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when adding logging, metrics, or instrumentation — Logger metadata, :telemetry handlers, Telemetry.Metrics tags, Ecto/Phoenix events, LiveDashboard, OpenTelemetry. Not for deployment config (use deployment-gotchas).

SKILL.md

telemetry-essentials.SKILL.md
name: telemetry-essentials
description: "Use when adding logging, metrics, or instrumentation — Logger metadata, :telemetry handlers, Telemetry.Metrics tags, Ecto/Phoenix events, LiveDashboard, OpenTelemetry. Not for deployment config (use deployment-gotchas)."

Telemetry Essentials

> Verified against Elixir 1.20, Phoenix 1.8, LiveView 1.2, Ecto 3.14 — October 2026

RULES — Follow these with no exceptions

1. **Put values you'll filter on in Logger metadata; a human-readable message may interpolate** — `Logger.info("Order #{order.id} created", order_id: order.id)`; `require Logger` first, and list the metadata keys in the formatter config or they're dropped 2. **Configure log output with `config :logger, :default_formatter` (or `:default_handler` for a JSON formatter)** — `config :logger, :console` is ignored when `:default_formatter` is set, which Phoenix 1.8's `config.exs` does 3. **Don't overwrite `request_id`** — `Plug.RequestId` (in every generated endpoint) already sets it as Logger metadata; it is not in `conn.assigns` 4. **Attach handlers once at boot, with `&__MODULE__.handle_event/4` and a catch-all clause** — a handler that raises is detached permanently and silently; a local capture (`&handle_event/4`) makes telemetry warn about a performance penalty 5. **Use the events Ecto, Phoenix and Oban already emit** — don't wrap queries manually; of Ecto's query measurements only `total_time` is always present, so read the others with `Map.get/2` 6. **Metric tags must have low cardinality** — route, status, queue, `source` yes; `user_id`, `request_id`, email never. Per-user ids belong in log metadata and traces 7. **If the project has its own telemetry wrapper or event catalog, use it** instead of calling `:telemetry.execute/3` directly 8. **Logger metadata is per process** — `Task`, `GenServer` and LiveView processes don't inherit it; pass it on explicitly 9. **LiveDashboard stays inside the generated `dev_routes` block, or behind admin auth** — never in a scope with only `:browser` 10. **Never log at `:debug` in production** — it includes query parameters (see the `deployment-gotchas` skill)

---

Logging

Metadata is what log tools index and filter on. The message is for humans and may interpolate.

<!-- epg:bad -->

# Nothing to filter on, and inspect(user) dumps the whole struct
Logger.info("User #{inspect(user)} created order")
require Logger

Logger.info("Order #{order.id} created", order_id: order.id, user_id: order.user_id)
Logger.error("Payment failed: #{inspect(reason)}", order_id: order.id, reason: inspect(reason))

Metadata keys not listed in the formatter config are silently dropped:

# config/config.exs — Phoenix 1.8 generates this block; extend the list
config :logger, :default_formatter,
  format: "$time $metadata[$level] $message\n",
  metadata: [:request_id, :user_id, :order_id]

JSON logs in production

[logger_json](https://hex.pm/packages/logger_json) 7 plugs into Elixir's default handler:

# mix.exs
{:logger_json, "~> 7.0"}

# config/runtime.exs
if config_env() == :prod do
  config :logger, :default_handler,
    formatter: LoggerJSON.Formatters.Basic.new(metadata: [:request_id, :user_id, :order_id])
end

`Basic.new/1` is only available in `runtime.exs` (deps are loaded by then). In `config.exs`/`prod.exs` use the tuple form `{LoggerJSON.Formatters.Basic, metadata: [...]}`. Formatters for Datadog, Google Cloud and Elastic exist alongside `Basic`.

<!-- epg:bad -->

# Ignored: :console is copied to :default_formatter only when :default_formatter
# is unset, and Phoenix 1.8's config.exs sets it
config :logger, :console, format: "$message\n", metadata: [:request_id]

Per-request and per-user metadata

`request_id` is already set by `Plug.RequestId`. Add the user once the scope is known, in a router plug and in a LiveView `on_mount` hook. A LiveView runs in its own process, so plug metadata doesn't reach it.

# router.ex — after :fetch_current_scope_for_user in the :browser pipeline
plug :put_user_log_metadata

defp put_user_log_metadata(conn, _opts) do
  with %{user: %{id: user_id}} <- conn.assigns[:current_scope] do
    Logger.metadata(user_id: user_id)
  end

  conn
end
defmodule MyAppWeb.LogMetadata do
  def on_mount(:default, _params, _session, socket) do
    with %{user: %{id: user_id}} <- socket.assigns[:current_scope] do
      Logger.metadata(user_id: user_id)
    end

    {:cont, socket}
  end
end

# router.ex — after the hook that assigns current_scope
live_session :require_authenticated_user,
  on_mount: [{MyAppWeb.UserAuth, :require_authenticated}, MyAppWeb.LogMetadata] do
  # live routes
end

`Logger.metadata/1` is a plain function, so it needs no `require Logger` (an unused `require` is a warning).

Spawned processes lose metadata

metadata = Logger.metadata()

Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
  Logger.metadata(metadata)
  deliver_webhook(event)
end)

The same applies to Oban jobs: put the ids you need in the job args and set metadata in `perform/1`. OpenTelemetry carries trace context across processes, but only in code instrumented to propagate it.

---

:telemetry Basics

# name (list of atoms), measurements (map), metadata (map)
:telemetry.execute([:my_app, :orders, :created], %{count: 1, total_cents: 4999}, %{source: :web})

Attaching handlers

# lib/my_app/application.ex
@impl true
def start(_type, _args) do
  MyApp.TelemetryHandlers.attach()

  children = [
    MyAppWeb.Telemetry,
    MyApp.Repo,
    MyAppWeb.Endpoint
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
defmodule MyApp.TelemetryHandlers do
  require Logger

  @events [
    [:my_app, :orders, :created],
    [:my_app, :payments, :failed]
  ]

  def attach do
    :telemetry.attach_many("my-app-handlers", @events, &__MODULE__.handle_event/4,
Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.