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 ~8 KB on disk (ETF + gzip).
Add the dependency:
def deps do [{:phoenix_replay, "~> 0.1.0"}] endAttach the recorder to a live session:
live_session :default, on_mount: [PhoenixReplay.Recorder] do live "/dashboard", DashboardLive live "/posts", PostLive.Index endMount the replay dashboard:
import PhoenixReplay.Router scope "/" do pipe_through :browser phoenix_replay "/replay" endVisit /replay to browse recordings and replay sessions with a scrubber, play/pause, and speed controls. Every connected LiveView in the live session is recorded automatically — mount params, events, navigation, and assign deltas. Sessions with no user interaction are discarded.
- The
on_mounthook attaches lifecycle hooks to each connected LiveView. - Session start sends a single async cast to the Store GenServer to set up a process monitor.
- All subsequent events are written directly to ETS (
ordered_setwithwrite_concurrency) — no GenServer messages on the hot path. - When the LiveView process exits, the Store finalizes and persists the recording via the configured storage backend.
| Event | Data |
|---|---|
| Mount | View module, URL, params, session, initial assigns |
| Handle event | Event name, params |
| Handle params | URL, params |
| Handle info | Type marker only |
| After render | Changed assigns (delta, or full snapshot when batched) |
Each event includes a millisecond offset from session start.
config :phoenix_replay, max_events: 10_000, sanitizer: MyApp.ReplaySanitizerActive recordings live in ETS. When a LiveView process exits, the recording is persisted via the configured backend.
File (default):
config :phoenix_replay, storage: PhoenixReplay.Storage.File, storage_opts: [path: "priv/replay_recordings", format: :etf]Ecto:
config :phoenix_replay, storage: PhoenixReplay.Storage.Ecto, storage_opts: [repo: MyApp.Repo, format: :etf]Requires a migration:
defmodule MyApp.Repo.Migrations.CreatePhoenixReplayRecordings do use Ecto.Migration def change do create table(:phoenix_replay_recordings, primary_key: false) do add :id, :string, primary_key: true add :view, :string, null: false add :connected_at, :bigint, null: false add :event_count, :integer, null: false, default: 0 add :data, :binary, null: false timestamps(type: :utc_datetime) end end endBoth backends support :etf (default — fast, preserves Elixir types) and :json (portable but lossy).
The default sanitizer strips internal LiveView keys and sensitive fields, and compacts Form, Changeset, and Ecto structs. To customize:
defmodule MyApp.ReplaySanitizer do @drop [:__changed__, :flash, :uploads, :streams, :_replay_id, :_replay_t0, :csrf_token, :password, :current_password, :password_confirmation, :token, :secret, :my_custom_secret] def sanitize_assigns(assigns), do: Map.drop(assigns, @drop) def sanitize_delta(changed, assigns) do changed |> Map.keys() |> Enum.reject(&(&1 in @drop)) |> Map.new(fn key -> {key, Map.get(assigns, key)} end) end endTo record individual views instead of an entire live session:
def mount(params, session, socket) do {:ok, PhoenixReplay.Recorder.attach(socket, params, session)} endPhoenixReplay.Store.list_recordings() PhoenixReplay.Store.get_recording(id) PhoenixReplay.Store.get_active(id)- Real-time session observation via PubSub
- LiveComponent state tracking
- Configurable sampling (record N% of sessions)
- Session search and filtering
MIT
