Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

- Add `requester` field to `Zexbox.Metrics.ControllerSeries` (not populated by default).
- Add `Zexbox.Metrics.ControllerSeriesEnricher` behaviour and an `:enricher` option on `Zexbox.Metrics.start_link/1` (also configurable via `config :zexbox, :metrics_enricher`) for setting tags/fields on the controller series from the `conn` before it is written.

## 1.5.1 - 2026-02-05

- Handles cases where `$callers` and `$ancestors` may not be pids to avoid crashing metric handler.
Expand Down
37 changes: 33 additions & 4 deletions lib/zexbox/metrics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -67,25 +67,54 @@ defmodule Zexbox.Metrics do
@doc """
Starts the metrics supervisor and attaches the controller metrics.

Accepts an optional `:enricher` to customise the controller series before it
is written. See `Zexbox.Metrics.ControllerSeriesEnricher`.

children = [
{Zexbox.Metrics, enricher: {MyApp.MetricsEnricher, []}}
]

An enricher can also be set via application env
(`config :zexbox, :metrics_enricher, {MyApp.MetricsEnricher, []}`); the
`start_link/1` argument wins if both are present.

## Examples

iex> Zexbox.Metrics.start_link(nil)
{:ok, #PID<0.123.0>}

"""
@spec start_link(args :: any()) :: Supervisor.on_start()
def start_link(_args) do
def start_link(args) do
on_start = Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
attach_controller_metrics()
attach_controller_metrics(resolve_enricher(args))
on_start
end

defp attach_controller_metrics do
defp resolve_enricher(args) do
enricher = enricher_from_args(args) || Application.get_env(:zexbox, :metrics_enricher)
normalise_enricher(enricher)
end

defp enricher_from_args(args) when is_list(args), do: Keyword.get(args, :enricher)
defp enricher_from_args(_args), do: nil

defp normalise_enricher(nil), do: nil

defp normalise_enricher({module, opts}) when is_atom(module) do
{module, module.init(opts)}
end

defp normalise_enricher(module) when is_atom(module) do
{module, module.init([])}
end

defp attach_controller_metrics(enricher) do
Telemetry.attach(
"phoenix_controller_metrics",
[:phoenix, :endpoint, :stop],
&MetricHandler.handle_event/4,
nil
%{enricher: enricher}
)
end
end
60 changes: 60 additions & 0 deletions lib/zexbox/metrics/controller_series_enricher.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule Zexbox.Metrics.ControllerSeriesEnricher do
@moduledoc """
Behaviour for enriching a `Zexbox.Metrics.ControllerSeries` with values
derived from the request before it is written to InfluxDB.

This is the extension point for setting tags and fields on the controller
series that `Zexbox` does not know how to populate itself — for example,
identifying the caller via an API key description, propagating a tenant
identifier, or attaching anything else that lives on the `conn`.

## Configuring an enricher

Pass the enricher when starting `Zexbox.Metrics`:

children = [
{Zexbox.Metrics, enricher: {MyApp.MetricsEnricher, []}}
]

Or configure it via application env:

config :zexbox, :metrics_enricher, {MyApp.MetricsEnricher, []}

The form `MyApp.MetricsEnricher` (without options) is also accepted and is
equivalent to `{MyApp.MetricsEnricher, []}`.

## Implementing an enricher

Read whatever you need off the `conn` — typically values placed there by an
earlier plug — and return a new series. The recommended pattern is to do any
expensive work (e.g. database lookups) in your auth plug and stash the result
on `conn.assigns`, so the enricher just reads it:

defmodule MyApp.MetricsEnricher do
@behaviour Zexbox.Metrics.ControllerSeriesEnricher

alias Zexbox.Metrics.ControllerSeries

@impl true
def init(opts), do: opts

@impl true
def call(series, conn, _opts) do
case conn.assigns[:api_key_description] do
nil -> series
description -> ControllerSeries.field(series, :requester, description)
end
end
end

Enricher exceptions are caught by `Zexbox.Metrics.MetricHandler` and logged;
the un-enriched series is still written. Avoid blocking work inside `call/3`
— it runs in the request process.
"""

alias Zexbox.Metrics.ControllerSeries

@callback init(opts :: any()) :: any()
@callback call(series :: ControllerSeries.t(), conn :: map(), opts :: any()) ::
ControllerSeries.t()
end
11 changes: 11 additions & 0 deletions lib/zexbox/metrics/metric_handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ defmodule Zexbox.Metrics.MetricHandler do
false ->
measurements
|> create_controller_series(metadata)
|> enrich(metadata, config)
|> write_metric(config)

true ->
Expand All @@ -32,6 +33,16 @@ defmodule Zexbox.Metrics.MetricHandler do
Logger.error("Exception creating controller series: #{inspect(exception)}")
end

defp enrich(series, %{conn: conn}, %{enricher: {module, opts}}) do
module.call(series, conn, opts)
rescue
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in two minds about this. On one hand, it's swallowing an error instead of raising loudly. On the other hand, I'd rather not have metrics crash the process. Thoughts?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure what the best approach is either. I agree that metrics shouldn't crash a process however I also think that just swallowing errors here is opening the door to bugs slipping under the radar. If nothing else I think we should make it clear in the documentation that uncaught exceptions will be swallowed so enricher modules will need to have some kind of error handling of their own? Having said that, if we're basically telling people that they need their own rescue clauses in their enrichers is there any point in having one here?

Copy link
Copy Markdown
Collaborator Author

@DylanBlakemore DylanBlakemore May 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KentHawkings after some digging, I think this is necessary. An exception raised when gathering controller metrics here would detach :telemetry entirely, and metric collection would break until the app restarts. We have an outer rescue entirely, but this at least guarantees the minimal series will be written.

exception ->
Logger.error("Exception in controller series enricher: #{inspect(exception)}")
series
end

defp enrich(series, _metadata, _config), do: series

defp required_fields_missing?(%{conn: %{private: private}}) do
format = Map.get(private, :phoenix_format)
controller = Map.get(private, :phoenix_controller)
Expand Down
4 changes: 4 additions & 0 deletions lib/zexbox/metrics/models/controller_series.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ defmodule Zexbox.Metrics.ControllerSeries do
* http_referer - The referer of the request
* count - The number of requests
* request_id - The request ID of the request
* requester - An identifier for the caller (e.g. an API key description or
upstream service name). Not populated by default — set it from a
`Zexbox.Metrics.ControllerSeriesEnricher`.

The tags allowed are:

Expand Down Expand Up @@ -37,6 +40,7 @@ defmodule Zexbox.Metrics.ControllerSeries do
field(:trace_id)
field(:count)
field(:request_id)
field(:requester)
end

@doc """
Expand Down
52 changes: 52 additions & 0 deletions test/zexbox/metrics/metric_handler_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ defmodule Zexbox.Metrics.MetricHandlerTest do
end
end

defmodule RequesterEnricher do
@behaviour Zexbox.Metrics.ControllerSeriesEnricher

@impl true
def init(opts), do: opts

@impl true
def call(series, conn, _opts) do
ControllerSeries.field(series, :requester, conn.assigns[:api_key_description])
end
end

defmodule BoomEnricher do
@behaviour Zexbox.Metrics.ControllerSeriesEnricher

@impl true
def init(opts), do: opts

@impl true
def call(_series, _conn, _opts), do: raise("boom")
end

describe "handle_event/4" do
setup do
start_supervised!(ContextRegistry)
Expand Down Expand Up @@ -106,6 +128,36 @@ defmodule Zexbox.Metrics.MetricHandlerTest do
assert log =~ "Exception creating controller series:"
end

test "invokes the configured enricher and writes the enriched series", %{
event: event,
measurements: measurements,
metadata: metadata,
config: config
} do
metadata = put_in(metadata.conn.assigns[:api_key_description], "service-A")
config = Map.put(config, :enricher, {RequesterEnricher, []})

assert %ControllerSeries{fields: %ControllerSeries.Fields{requester: "service-A"}} =
MetricHandler.handle_event(event, measurements, metadata, config)
end

test "logs and falls back to the un-enriched series when the enricher raises", %{
event: event,
measurements: measurements,
metadata: metadata,
config: config
} do
config = Map.put(config, :enricher, {BoomEnricher, []})

log =
capture_log(fn ->
assert %ControllerSeries{fields: %ControllerSeries.Fields{requester: nil}} =
MetricHandler.handle_event(event, measurements, metadata, config)
end)

assert log =~ "Exception in controller series enricher:"
end

test "does not call Connection.write when process has disabled metrics", %{
event: event,
measurements: measurements,
Expand Down
33 changes: 33 additions & 0 deletions test/zexbox/metrics_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,44 @@ defmodule Zexbox.MetricsTest do
use ExUnit.Case
alias Zexbox.Metrics

defmodule EchoEnricher do
@behaviour Zexbox.Metrics.ControllerSeriesEnricher
@impl true
def init(opts), do: {:initialised, opts}
@impl true
def call(series, _conn, _opts), do: series
end

setup do
on_exit(fn ->
:telemetry.detach("phoenix_controller_metrics")

try do
Supervisor.stop(Metrics)
catch
:exit, _reason -> :ok
end
end)

:ok
end

test "start_link/1 starts the metrics supervisor" do
{:ok, pid} = Metrics.start_link(nil)
assert Process.alive?(pid)
end

test "start_link/1 normalises the enricher option through its init/1" do
{:ok, _pid} = Metrics.start_link(enricher: {EchoEnricher, [foo: :bar]})

handler =
[:phoenix, :endpoint, :stop]
|> :telemetry.list_handlers()
|> Enum.find(&(&1.id == "phoenix_controller_metrics"))

assert handler.config == %{enricher: {EchoEnricher, {:initialised, [foo: :bar]}}}
end

test "init/1 initializes the metrics supervisor" do
assert {:ok,
{%{intensity: 3, period: 5, strategy: :one_for_one, auto_shutdown: :never},
Expand Down
Loading