PhoenixReplay
Session recording and replay for Phoenix LiveView.
LiveView templates are pure functions: same assigns produce the same HTML. PhoenixReplay captures assigns at each state transition and replays them by re-rendering the original view — no client-side recording, no DOM snapshots, no JavaScript changes. A 30-second session with active form input is ~400 events and a few kilobytes on disk.
Quick start
Add the dependency:
def deps do
[{:phoenix_replay, "~> 0.3.0"}]
end
Attach the recorder to a live session:
live_session :default, on_mount: [PhoenixReplay.Recorder] do
live "/dashboard", DashboardLive
live "/posts", PostLive.Index
end
Mount the dashboard behind your admin pipeline:
import PhoenixReplay.Router
scope "/admin" do
pipe_through [:browser, :require_admin]
phoenix_replay "/replay",
on_mount: [{MyAppWeb.UserAuth, :ensure_admin}],
authorize: MyApp.ReplayAuthorization
end
Add :phoenix_replay to import_deps in .formatter.exs so the macro keeps its parentheses-free form.
Visit /admin/replay to browse recordings and replay them with a scrubber, keyboard controls, and playback speeds. Every connected LiveView in the live session is recorded. Sessions without user interaction are discarded.
How it works
PhoenixReplay.Recorderstarts recording on the connected mount and attaches lifecycle hooks. Its state lives insocket.private, so your assigns are untouched.- The LiveView process writes each event straight into an ETS buffer owned by the application, with no message passing on the hot path.
PhoenixReplay.Recorder.Monitorwatches the process. When it exits, the recording is saved in a supervised task with retries, then removed from the buffer. The buffer outlives worker restarts, and the monitor re-attaches to buffered sessions when it starts.- Replay re-renders your view's own template with the recorded assigns inside an iframe. Each player drives its frame over a private channel, so viewers never interfere with each other.
Recorded events
| Event | Data |
|---|---|
:mount |
Assigns when recording started |
:params |
handle_params/3 params and URI |
:event |
handle_event/3 name and params |
:info |
handle_info/2 message tag only, never message contents |
:render |
Assigns changed by the render |
Each event carries a millisecond offset from the start of the session.
Current limitations
Replay reconstructs root LiveView assigns. It does not fully reconstruct LiveComponents, streams, uploads, client-only JavaScript state, or pushed JS events. Templates that fail to render with the recorded assigns show a placeholder at that position.
Dashboard
Authorization
Recordings can contain business data even after sanitization. Always mount the dashboard behind authentication, and use PhoenixReplay.Authorization for per-recording rules:
defmodule MyApp.ReplayAuthorization do
@behaviour PhoenixReplay.Authorization
@impl true
def authorize(:clear, _subject, socket), do: socket.assigns.current_user.admin?
def authorize(_action, _recording, socket), do: socket.assigns.current_user != nil
end
Actions are :list, :view, :delete and :clear. Recordings a viewer may not see respond with 404.
Frame layout
The replay frame renders your views, so it needs your stylesheet. By default it loads /assets/css/app.css through your endpoint's static_path/1. If your assets are content-hashed by a bundler such as Volt, render the frame in your own root layout instead:
phoenix_replay "/replay", frame_layout: {MyAppWeb.Layouts, :root}
Assets
The dashboard ships its own small script and stylesheet and loads your application's own Phoenix and LiveView clients, so the client always matches your server version. It needs nothing from your asset pipeline. If your LiveView socket is not mounted at /live, pass live_socket_path: "/socket/live".
Configuration
config :phoenix_replay,
storage: {PhoenixReplay.Storage.File, path: "priv/replay_recordings"},
sanitizer: PhoenixReplay.Sanitizer.Default,
max_events: 10_000,
retention: [max_age: :timer.hours(24 * 7), max_count: 1_000, interval: :timer.minutes(10)],
persist: [attempts: 3, backoff: 1_000]
All keys are optional and validated at startup; see PhoenixReplay.Config.
Storage backends
File (default) stores each recording as a compressed Erlang term, plus a small summary file so listing never decodes recordings:
config :phoenix_replay, storage: {PhoenixReplay.Storage.File, path: "/var/lib/my_app/replays"}
Ecto stores recordings in a table:
config :phoenix_replay, storage: {PhoenixReplay.Storage.Ecto, repo: MyApp.Repo}
See PhoenixReplay.Storage.Ecto for the migration. Implement PhoenixReplay.Storage for any other backend.
Sanitizer
PhoenixReplay.Sanitizer.Default replaces the values of keys containing password, token, secret, api_key, private_key or credential with "[FILTERED]", recursing through maps, lists, tuples and structs, and compacts changesets and forms. To customize, implement PhoenixReplay.Sanitizer and delegate what you keep:
defmodule MyApp.ReplaySanitizer do
@behaviour PhoenixReplay.Sanitizer
@impl true
def sanitize_assigns(assigns) do
assigns
|> Map.drop([:current_user])
|> PhoenixReplay.Sanitizer.Default.sanitize_assigns()
end
@impl true
defdelegate sanitize_params(params), to: PhoenixReplay.Sanitizer.Default
end
Telemetry
PhoenixReplay.Telemetry documents the [:phoenix_replay, :recording, :persisted | :discarded | :failed] events.
Programmatic access
config = PhoenixReplay.Config.load()
PhoenixReplay.Recordings.list(config)
PhoenixReplay.Recordings.fetch(config, id)
PhoenixReplay.Recordings.delete(config, id)
PhoenixReplay.Recordings.clear(config)
Development
mix deps.get
npm ci
npx playwright install chromium
mix ci
The dashboard's TypeScript and CSS live in priv/ts and priv/css, linted and type-checked by mix volt.js.check. Their *.test.ts files run under mix test through Volt's test runner: pure modules in QuickBEAM, LiveView hooks in Chromium. Rebuild the committed bundle with mix assets.build; mix ci fails when it is stale.
Roadmap
- LiveComponent state tracking
- Configurable sampling (record N% of sessions)
- Session search and filtering
Part of Elixir Vibe
PhoenixReplay records LiveView sessions as assigns timelines, making every session replayable and every bug reproducible.
It is one building block of a larger stack — tools that make AI-generated software checkable: structural search, dependence analysis, duplication and slop detection, session replay, and ecosystem-wide code search. See the Elixir Vibe organization for the rest, and Building Blocks for the Future Web for the thesis, architecture, and roadmap that tie them together.
License
MIT