Instrumenting LLM Calls in Phoenix with Telemetry
ReqLLM already emits token cost and latency telemetry events. The real gap is a few lines wiring them into LiveDashboard and PromEx, not another SaaS bill.
TL;DR: ReqLLM — the Req-based LLM client for Elixir — already emits
:telemetryevents with token counts, calculated cost, and request duration on every call, no wrapper code required. The gap most Phoenix teams have isn’t visibility, it’s five lines ofTelemetry.Metricsconnecting an event that already exists to a dashboard that already ships with Phoenix. This post is the practitioner path: what ReqLLM emits and where I verified it, how to wire it into LiveDashboard for local dev, how to ship the same numbers to Prometheus with a PromEx plugin for production, the minimal hand-rolled version if you’re on a raw HTTP client instead, and the two numbers actually worth paging someone over.
The “what if I don’t want to buy Helicone” question
I wrote the case for treating LLM telemetry as a first-class layer, not an afterthought — the argument that most “the model is bad” complaints are actually “we can’t see what the model is doing” problems, and that a telemetry layer usually fixes more than a model upgrade does. That post is deliberately stack-agnostic and points at hosted options like Helicone and Langfuse, because most teams asking “how do I see what my LLM calls are doing” are on Python and a hosted proxy is the fastest path.
If you’re running Phoenix, you already have two other options that a
Python shop doesn’t: a BEAM-native dashboard that ships in every
phx.new app, and a Prometheus/Grafana story that’s one
dependency away. This post is the “okay, but I don’t want to route every
LLM call through a third-party proxy” answer for that stack. It assumes
you’ve already read (or don’t need) the argument for why
telemetry matters, and just want the wiring.
One more adjacent post worth knowing about first: testing AI agent outputs
with ExUnit covers
:telemetry.attach/:telemetry.execute as
primitives, using a test-time event that asserts on tool-call order. I
won’t re-explain what those functions do here — if you haven’t used
:telemetry before, read that post’s first section, then
come back. This is the production sibling of that same event system:
instead of asserting on an event in a test, you’re aggregating it into
cost-per-day and p95 latency.
What ReqLLM already gives you for free
If your Phoenix app talks to an LLM through ReqLLM — the
Req/Finch-based client from the Jido
ecosystem, covering Anthropic, OpenAI, Google, Bedrock, and a dozen
other providers behind one interface — you don’t need to write a
:telemetry.span around your LLM calls. ReqLLM already does
it. I verified the exact event shape against the library’s telemetry
guide and the ReqLLM.Telemetry source rather than trust
the README summary, because getting an event name or a measurement key
wrong here means a wired-up dashboard that silently reads zeros.
ReqLLM emits two families of events:
The request span — [:req_llm, :request, :start]
/ :stop / :exception. This is what
the docs now recommend for new integrations, because the
:stop event carries both duration and the full request
context in one place. Measurements on :stop are
%{duration: native_time, system_time: ...}.
duration is in native monotonic units, the same convention
Phoenix’s own endpoint/request telemetry uses, so you convert it with
unit: {:native, :millisecond} wherever you consume it
rather than doing the math by hand. Metadata on :stop
includes request_id, operation,
mode, provider, model (an
LLMDB.Model struct, not a string),
http_status, finish_reason, and the field that
matters for cost: usage, a map with token counts and cost
fields.
The compat event —
[:req_llm, :token_usage]. A narrower event fired
specifically for cost/token aggregation, and the one most tutorials will
show you first because it’s simpler. Its measurements map nests the raw
usage numbers under a tokens key
(%{tokens: %{input_tokens: _, output_tokens: _, total_tokens: _}, cost: _, total_cost: _, ...})
rather than flattening them — worth an IO.inspect in
iex against your installed version before you write metric
definitions against it, because compat-layer field shapes are exactly
the kind of thing that shifts between minor releases without a changelog
headline.
Either event gets you cost and tokens without touching your LLM call
sites. That’s the actual finding here: the boilerplate you’d expect to
write — wrap every provider call in :telemetry.span, thread
through model name and token counts by hand — doesn’t need writing if
ReqLLM is already your client.
Wiring it into LiveDashboard in dev
Phoenix’s Telemetry.Metrics module is the layer both
LiveDashboard and PromEx consume — you define metrics once, against
event names, and both reporters subscribe to the same list. Every
phx.new --live app already has a
YourAppWeb.Telemetry module with a metrics/0
function full of Phoenix and Ecto metrics. Add ReqLLM’s events to that
same list:
# lib/my_app_web/telemetry.ex
defp req_llm_metrics do
[
distribution(
"req_llm.request.duration",
event_name: [:req_llm, :request, :stop],
measurement: :duration,
unit: {:native, :millisecond},
tags: [:provider, :model],
tag_values: fn meta ->
%{
provider: meta[:provider],
model: meta[:model] && meta[:model].id
}
end,
description: "LLM request latency"
),
sum(
"req_llm.tokens.total",
event_name: [:req_llm, :token_usage],
measurement: fn m ->
get_in(m, [:tokens, :total_tokens]) || 0
end,
tags: [:provider],
tag_values: &%{provider: &1[:provider]},
description: "Tokens consumed, cumulative"
),
sum(
"req_llm.cost.total",
event_name: [:req_llm, :token_usage],
measurement: fn m -> m[:total_cost] || 0 end,
tags: [:provider, :model],
tag_values: fn meta ->
%{
provider: meta[:provider],
model: meta[:model] && meta[:model].id
}
end,
description: "Calculated USD cost, cumulative"
),
counter(
"req_llm.request.count",
event_name: [:req_llm, :request, :stop],
tags: [:provider, :finish_reason]
)
]
endThen splice req_llm_metrics() into the existing
metrics/0 list. That’s the whole integration — no attach
calls, no GenServer to hold running totals. Telemetry.Metrics
handles the subscription; LiveDashboard’s Metrics tab picks up anything
in that list automatically the moment you
PORT=4001 mix phx.server and make a call.
distribution gives you a live latency histogram per
provider/model, sum gives you a running token and cost
counter, counter gives you call volume broken out by
finish_reason — which is the fastest way to notice a
provider silently truncating responses before a user complains.
The tag_values function matters more than it looks:
model in ReqLLM’s metadata is an LLMDB.Model
struct, and neither LiveDashboard nor Prometheus wants a struct as a
label value, so pull .id out before it hits the tag
list.
Shipping the same numbers to Prometheus with PromEx
LiveDashboard is a great local and staging tool, but it only shows
you the current node’s in-memory state — it doesn’t survive a restart or
aggregate across a fleet. For anything you’d actually alert on, ship the
same Telemetry.Metrics definitions to Prometheus via PromEx, which is a plugin
over the exact same event/metric model — you’re not learning a second
API, you’re pointing the same concepts at a different reporter.
A custom PromEx plugin for ReqLLM, following the plugin-writing guide:
defmodule MyApp.PromEx.Plugins.ReqLLM do
use PromEx.Plugin
@impl true
def event_metrics(_opts) do
Event.build(
:req_llm_event_metrics,
[
distribution(
[:req_llm, :request, :duration, :milliseconds],
event_name: [:req_llm, :request, :stop],
measurement: :duration,
unit: {:native, :millisecond},
tags: [:provider],
tag_values: fn meta ->
%{provider: meta[:provider]}
end,
reporter_options: [
buckets: [100, 500, 1_000, 3_000, 10_000]
]
),
sum(
[:req_llm, :cost, :usd, :total],
event_name: [:req_llm, :token_usage],
measurement: fn m -> m[:total_cost] || 0 end,
tags: [:provider]
)
]
)
end
endRegister it alongside the built-in plugins in your app’s PromEx module:
defmodule MyApp.PromEx do
use PromEx, otp_app: :my_app
@impl true
def plugins do
[
PromEx.Plugins.Application,
PromEx.Plugins.Beam,
PromEx.Plugins.Phoenix,
{MyApp.PromEx.Plugins.ReqLLM, []}
]
end
endFrom there it’s standard PromEx: mix prom_ex.gen.config
if you haven’t set up a datasource yet, and the metrics show up at
/metrics for Prometheus to scrape. The buckets on the
duration histogram are the one thing worth tuning by hand. LLM calls run
in the hundreds-of-milliseconds to tens-of-seconds range, not the
single-digit milliseconds Phoenix’s own request histogram defaults
assume, so the stock bucket boundaries will bucket almost every LLM call
into the same “overflow” bucket if you don’t override them.
If you’re not on ReqLLM: the minimal hand-rolled span
If your app calls a provider’s SDK directly, or does raw
Req/Finch requests without ReqLLM, you don’t
get the events above for free — but you also don’t need much code to get
equivalent ones. :telemetry.span/3 does the
start/stop/exception bookkeeping for you:
defmodule MyApp.LLM do
def complete(prompt, opts \\ []) do
metadata = %{
provider: opts[:provider] || :openai,
model: opts[:model]
}
:telemetry.span(
[:my_app, :llm, :request],
metadata,
fn ->
response = call_provider(prompt, opts)
usage = extract_usage(response)
result_meta =
Map.merge(metadata, %{
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cost: usage.cost
})
{response, result_meta}
end
)
end
end:telemetry.span/3 fires
[:my_app, :llm, :request, :start] before the function runs
and ..., :stop] after, with duration computed
for you — and ..., :exception] if the function raises,
which matters here because a provider timeout is exactly the failure
mode you want in the dashboard, not just the successes. The map your
function returns becomes the :stop event’s metadata — which
is why the code merges the token counts into metadata by
hand before returning it; that’s how they make it into the event without
a second :telemetry.execute call. Everything downstream —
the Telemetry.Metrics definitions, LiveDashboard, PromEx —
is identical to the ReqLLM path; only the event name and the code that
produces it changes.
The two numbers worth alerting on
Once the metrics exist, resist the urge to build a dashboard with fifteen panels nobody opens. Two numbers actually change what you do:
Cost per day, trending. A sum metric on
cost, viewed daily, catches the failure mode that burns money silently:
a prompt template that ballooned after an edit, a retry loop with no
backoff hammering the most expensive model on every failure, a feature
that got popular faster than anyone expected. This is the same instinct
behind checking inference unit economics in technical due
diligence — the number that’s fine at ten calls a day and ruinous at
ten thousand needs a trend line, not a spot check.
p95 latency per provider. Not the average — the
average hides the calls that are actually annoying a user right now. A
distribution metric with tags on provider (and model, if
you route between them) tells you when a specific provider degrades
before your support inbox does. If you’re running an agent loop that
retries on failure — the kind of durable, idempotent loop Oban makes a good runtime
for — a latency spike on one provider is also the leading indicator
that your retry budget is about to get expensive, because every retry is
another billed call on top of the original.
The boilerplate you’d expect to write for LLM telemetry usually
doesn’t need writing — the event already exists, and the gap is five
lines of Telemetry.Metrics.
Everything else — token counts by endpoint, breakdown by finish reason, per-model histograms — is worth having in the dashboard for debugging, but it’s not worth an alert. If you’re already streaming tokens to the client with LiveView’s async APIs, the same PubSub-friendly instinct applies here: don’t build more real-time surface area than someone is actually going to look at.
One more option worth knowing about
If you want tracing and not just metrics — spans that follow a
request through an agent’s tool calls, not just a single provider call —
agent_obs
is a small Elixir library built specifically for this, with
trace_agent/3 and trace_llm/3 helpers that
emit OpenTelemetry-compatible spans (with an Arize Phoenix integration
via OpenInference conventions). It’s a different shape of problem than
what this post covers — cost/latency dashboards versus distributed
tracing — but if the next question after this post is “now I need to see
the whole agent run, not just one call,” that’s where I’d look
first.
Where this fits
None of this requires choosing between “buy Helicone” and “build it yourself” as an all-or-nothing decision — plenty of teams run both, a hosted tool for the LLM-specific views (prompt diffing, eval scoring) and Telemetry.Metrics for the operational numbers that live next to the rest of their Phoenix metrics anyway. What it does mean is that if you’re already on the BEAM — and there are real reasons to be, not just aesthetic ones — the telemetry story for LLM calls is closer to “wire up what’s already there” than “adopt a new platform.”
If you’re building out an AI product’s operational layer and want a second set of eyes on what’s actually load-bearing versus what’s dashboard theater, that’s a conversation I have often as a fractional CTO for AI startups — happy to look at what you’ve got.