A plugin adds a tab and badges to the toolbar. The built-in Ecto, Request, LiveView, Process and System panels are plugins too, written against the same API. This guide builds one step by step; the complete example is the Oban plugin in the repository's examples/oban folder.

Experimental

The plugin API may change in any 0.0.x release and is unstable before 1.0: changes are listed in the CHANGELOG.

Where the module lives

The toolbar is a dev only dependency (only: :dev): in :prod (and :test) PhoenixDevToolbar.Plugin does not exist, so a plugin module in lib/ breaks those builds. Keep plugins in a directory compiled in :dev only:

# mix.exs
def project do
  [elixirc_paths: elixirc_paths(Mix.env()), ...]
end

defp elixirc_paths(:dev), do: ["lib", "dev"]
defp elixirc_paths(_env), do: ["lib"]

and put MyApp.Toolbar.Jobs in dev/toolbar/jobs.ex, registered from config/dev.exs. A plugin published as its own package declares the toolbar as a dependency and is installed only: :dev as well.

Where your code runs

A plugin has three parts, each running in a different place:

PartCallbacksRuns in
Collectioninit/1, telemetry_events/1, collect/3the host app's processes
Summarysummary_init/1, summary_add/2, badges/1the toolbar panel
Taba PhoenixDevToolbar.Panel LiveComponentthe toolbar panel

collect/3 is the one to be careful with. It runs inside the host process that emitted the telemetry event: a request, a LiveView, a process calling your Repo. The toolbar only calls it for processes it tracks (the page you are looking at and its Task.async children), but there it runs on every event:

  • Keep it cheap and pure: no GenServer.call, no I/O, no Process.sleep.
  • Bound everything you keep. Use PhoenixDevToolbar.Truncate: string/2 (binaries only), inspect_value/2, and preview/2 for user data such as params or job args: it redacts keys such as password and cuts what it does not walk, however deep. Never store a whole struct, a socket or a conn.
  • Never create atoms from runtime data.
  • Never assume a metadata field is present or has a type: a value you did not expect is better shown as inspect_value/1 than raised on.

If collect/3 raises, throws, exits or returns anything other than {:record, event} or :skip, the toolbar disables the plugin (it is quarantined), logs the error once and shows it in the plugin's tab with a Re-enable button (in the summary bar for a plugin without a tab). The host app and the other plugins keep working.

Attribution: what counts as "the page"

An event belongs to a page when it is emitted by a process the toolbar tracks: the request that rendered the page, its LiveView, or a $callers child of them (Task.async, Task.async_stream). Context.source tells which: :self or :caller.

Work done in other processes is not attributed: a GenServer the LiveView calls, a PubSub subscriber, a job runner. For Oban, the insert of a job runs in the caller (the LiveView that enqueued it), so it shows; the job's execution runs in Oban's queue processes, which belong to no page, so it does not (except with testing: :inline, where jobs run in the caller).

1. Collection

defmodule MyApp.Toolbar.Jobs do
  use PhoenixDevToolbar.Plugin, id: :jobs, name: "Jobs"

  alias PhoenixDevToolbar.{Context, Event, Truncate}

  # Runs once, when the toolbar starts: validate options here.
  @impl PhoenixDevToolbar.Plugin
  def init(opts) do
    case Keyword.get(opts, :queues, :all) do
      :all -> {:ok, %{queues: :all}}
      queues when is_list(queues) -> {:ok, %{queues: Enum.map(queues, &to_string/1)}}
      other -> {:error, "queues must be :all or a list, got: #{inspect(other)}"}
    end
  end

  @impl PhoenixDevToolbar.Plugin
  def telemetry_events(_config), do: [[:oban, :engine, :insert_job, :stop]]

  # Runs in the host process. The config is the one init/1 returned.
  @impl PhoenixDevToolbar.Plugin
  def collect(_event, {measurements, %{job: job}}, %Context{config: config}) do
    case config.queues == :all or job.queue in config.queues do
      true ->
        data = %{worker: Truncate.inspect_value(job.worker), args: Truncate.preview(job.args, 512)}
        {:record, Event.new(:jobs, :inserted, data, duration: measurements[:duration])}

      false ->
        :skip
    end
  end

  def collect(_event, _payload, _context), do: :skip
end
  • id is stored in every event (Event.plugin, set by the toolbar to your plugin's id) and names the tab (#pdt-tab-jobs); it must be unique among the configured plugins.
  • Event.new/4 takes the plugin id, a kind of your choosing, the data map (already bounded: it is stored as is) and an optional :duration in native time units, as :telemetry reports it.
  • Return :skip for events you do not want.

Register it in config/dev.exs:

config :phoenix_dev_toolbar,
  plugins: [:defaults, {MyApp.Toolbar.Jobs, queues: [:mailers]}]

An invalid entry (a module that does not implement the behaviour, an init/1 returning an error, malformed event names, half of the summary callbacks) is logged and skipped: your app always boots.

2. Summary badges

Badges are shown in the collapsed bar. They are folded event by event in the panel, so they keep counting when the oldest events are trimmed from the buffer (each plugin keeps its last 500 events per session; a LiveView page has two sessions, its request and its LiveView).

  @impl PhoenixDevToolbar.Plugin
  def summary_init(_config), do: 0

  @impl PhoenixDevToolbar.Plugin
  def summary_add(count, %Event{kind: :inserted}), do: count + 1
  def summary_add(count, _event), do: count

  @impl PhoenixDevToolbar.Plugin
  def badges(0), do: []
  def badges(count), do: [%{id: "jobs", text: "#{count} jobs", level: :info}]

Define all three callbacks or none. A badge that is not well formed is dropped, and an error in any of the three disables the plugin in the panel, like an error in its tab. A badge is a map with id (the DOM id becomes pdt-badge-<id>: start it with your plugin's id), text, level (:ok, :info, :warn, :error) and an optional title tooltip.

3. The tab

panel/0 returns a LiveComponent that does use PhoenixDevToolbar.Panel:

  @impl PhoenixDevToolbar.Plugin
  def panel, do: MyApp.Toolbar.JobsPanel
defmodule MyApp.Toolbar.JobsPanel do
  use PhoenixDevToolbar.Panel

  alias PhoenixDevToolbar.Panel

  # Events have no :id field: DOM ids come from the unique seq.
  @impl Phoenix.LiveComponent
  def mount(socket), do: {:ok, stream_configure(socket, :jobs, dom_id: &"jobs-#{&1.seq}")}

  # A new page (the panel opened, live navigation): every stored event.
  @impl Panel
  def handle_reset(events, socket), do: stream(socket, :jobs, events, reset: true, limit: -500)

  # New events since, oldest first.
  @impl Panel
  def handle_events(events, socket),
    do: Enum.reduce(events, socket, &stream_insert(&2, :jobs, &1, limit: -500))

  @impl Phoenix.LiveComponent
  def render(assigns) do
    ~H"""
    <ul id="jobs" class="pdt-list" phx-update="stream">
      <li id="jobs-empty" class="pdt-list__empty">No jobs yet.</li>
      <li :for={{id, event} <- @streams.jobs} id={id} class="pdt-row">
        <code>{event.data.worker}</code>
        <span class="pdt-time">{Panel.duration(event.duration)}</span>
      </li>
    </ul>
    """
  end
end
  • Keep events in streams, never in plain assigns: a page can produce thousands.
  • The toolbar assigns @config, @page (@page.root is the page's request sid), @shown? (the tab is selected) and @visible? (the panel is open and the browser tab visible). handle_props/1 runs when one of them changes: use it to start or stop work that only matters while someone looks.
  • Everything a LiveComponent can do works: handle_event/3 with phx-target={@myself}, start_async/3 and handle_async/3 for slow reads. For timers, PhoenixDevToolbar.Panel.send_after/4 delivers a message to handle_info/2. Do not define update/2 or update_many/1: the toolbar defines them.
  • Style with the panel's classes: pdt-list, pdt-row, pdt-row__head, pdt-row__meta, pdt-row--error, pdt-tag, pdt-badge, pdt-time, pdt-origin, pdt-error, pdt-empty, pdt-list__empty, pdt-hint, pdt-button. This is the stable set; other pdt- classes are internal and may change. The panel lives in its own iframe: your markup can never break the host page, and the panel stylesheet is the only one loaded.
  • DOM ids must be unique in the whole panel: prefix them with your plugin id.

Linking to another tab

PhoenixDevToolbar.Panel.navigate/3 switches the panel to another plugin's tab and hands it {:navigate, params} in its handle_info/2:

  # In your tab: a click shows the process in the System tab.
  @impl Phoenix.LiveComponent
  def handle_event("inspect", %{"pid" => pid}, socket),
    do: {:noreply, Panel.navigate(socket, :system, %{"pid" => pid})}

  # In the target tab.
  @impl Panel
  def handle_info({:navigate, %{"pid" => pid}}, socket), do: assign(socket, :pid, pid)

Keep params small: a few strings. A plugin that is not attached, or has no tab, is ignored. Treat params like any client input: they may come from a click. To point at a process, link to the System tab rather than acting on it yourself: Panel.navigate(socket, :system, %{"pid" => inspect(pid)}).

What a tab does not get: the built-in tabs' internal assigns (prefixed pdt_, such as the client's address the process actions check) and process monitor messages. A DOWN for a monitor your tab sets reaches the panel process, which only routes the built-in tabs' own: use send_after/4 polling, or start_async/3 and handle_async/3, to follow a process.

An exception in the tab's code (its callbacks, render/1 and its template, mount/1) disables the tab only, with the error and a Re-enable button. The template is checked by rendering it once more behind that boundary; a template that nests live components is not checked, and an error there still breaks the panel.

Testing a plugin

  • Unit test collect/3 with the metadata shape the library really emits: read it in the library's source (deps/), not from memory.
  • Unit test the summary callbacks: they are pure functions.
  • To test against the toolbar, install it only: [:dev, :test] and compile dev/ in :test too.
  • The whole path (a page of your app, the injected sid, the panel at /__pdt/panel?sid=<sid>) needs PhoenixDevToolbar.Plug in the test endpoint, which usually sits under code_reloading? and is off in tests: check it by hand in mix phx.server instead. The toolbar's own tests (test/examples/oban_test.exs) show the full path against a test endpoint.