Session recording and replay for Phoenix LiveView
  • Elixir 89%
  • JavaScript 5.9%
  • HTML 4.4%
  • CSS 0.7%
Find a file
2026-06-11 12:19:28 +03:00
.github/workflows Use shared Elixir CI workflow 2026-06-06 21:48:42 +03:00
config Storage adapters (File, Ecto), ETF/JSON serialization, redesigned player with time-based scrubber 2026-03-09 16:56:49 +03:00
example Add demo recording script and update Playwright tests 2026-03-10 08:02:53 +03:00
lib Add retention and dashboard controls 2026-05-25 14:32:49 +03:00
priv/static/phoenix_replay Add retention and dashboard controls 2026-05-25 14:32:49 +03:00
test Add retention and dashboard controls 2026-05-25 14:32:49 +03:00
.credo.exs Switch example app to SQLite, add CI tooling 2026-03-09 15:45:20 +03:00
.formatter.exs mix new phoenix_replay 2026-03-09 11:37:39 +03:00
.gitignore mix new phoenix_replay 2026-03-09 11:37:39 +03:00
.tool-versions Use Elixir 1.20 by default 2026-06-06 16:59:40 +03:00
CHANGELOG.md Bump version to 0.2.0 2026-05-25 14:41:35 +03:00
LICENSE Pre-release cleanup 2026-03-10 08:12:55 +03:00
mix.exs Use Elixir 1.20 by default 2026-06-06 16:59:40 +03:00
mix.lock Add retention and dashboard controls 2026-05-25 14:32:49 +03:00
README.md README: ecosystem footer linking org and Building Blocks standard 2026-06-11 12:19:28 +03:00
screenshot.jpg Add screenshot to README 2026-03-10 08:36:07 +03:00

PhoenixReplay

Session recording and replay for Phoenix LiveView.

PhoenixReplay dashboard replaying a form session

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).

Quick start

Add the dependency:

def deps do
  [{:phoenix_replay, "~> 0.1.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 replay dashboard:

import PhoenixReplay.Router

scope "/" do
  pipe_through [:browser, :require_admin]
  phoenix_replay "/replay"
end

Visit /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 — sanitized mount params, events, navigation, and assign deltas. Sessions with no user interaction are discarded.

Recordings can contain business data even after sanitization. Mount the dashboard only behind an authenticated admin pipeline. You can also add a final authorization callback:

config :phoenix_replay,
  authorize: fn recording -> recording.view in [MyAppWeb.SafeLive] end

How it works

  1. The on_mount hook attaches lifecycle hooks to each connected LiveView.
  2. Session start sends a single async cast to the Store GenServer to set up a process monitor.
  3. All subsequent events are written directly to ETS (ordered_set with write_concurrency) — no GenServer messages on the hot path.
  4. When the LiveView process exits, the Store finalizes the recording and hands persistence to a supervised worker.

Recorded events

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.

Current limitations

Replay is currently based on root LiveView assigns. It does not fully reconstruct stateful LiveComponents, streams, uploads, client-only JavaScript state, or pushed JS events. Those sessions may still be useful for debugging server-side state, but replay output can differ from what the browser showed.

Configuration

config :phoenix_replay,
  max_events: 10_000,
  sanitizer: MyApp.ReplaySanitizer,
  max_recordings: 1_000,
  max_recording_age_ms: 7 * 24 * 60 * 60 * 1000,
  cleanup_interval_ms: 60 * 60 * 1000,
  persistence_retry_attempts: 3,
  persistence_retry_delay_ms: 1_000

Storage backends

Active recordings live in ETS. When a LiveView process exits, the recording is persisted via the configured backend. Async persistence retries transient failures using :persistence_retry_attempts and :persistence_retry_delay_ms; cleanup can be limited by :max_recordings, :max_recording_age_ms, and :cleanup_interval_ms.

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
end

Both backends support :etf (default — fast, preserves Elixir types) and :json (portable but lossy).

Custom sanitizer

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_params(params), do: Map.drop(params, Enum.map(@drop, &Atom.to_string/1))

  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
end

Manual attachment

To record individual views instead of an entire live session:

def mount(params, session, socket) do
  {:ok, PhoenixReplay.Recorder.attach(socket, params, session)}
end

Programmatic access

PhoenixReplay.Store.list_recording_summaries()
PhoenixReplay.Store.list_recordings()
PhoenixReplay.Store.get_recording(id)
PhoenixReplay.Store.get_active(id)
PhoenixReplay.Store.delete_recording(id)
PhoenixReplay.Store.clear_all()
PhoenixReplay.Store.cleanup()

Roadmap

  • Real-time session observation via PubSub
  • 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