Watching
The plugin adds a Session replays resource (Packstub\SessionReplay\Filament\Resources\ReplaySessions\ReplaySessionResource) to the panel: a list and a watch page. Recordings are never created or edited by hand, so there are no create and edit pages.
The list
Newest first (started_at, descending).
| Column | What it shows |
|---|---|
| Person | The person's label (see People), "Guest" for a recording without a person, and "Impersonated by #id" underneath when someone was impersonating. Searching the table finds people by name or email, and by their key: type 42 to find user 42 (see People). |
| Workspace | The tenant's name, what tenantLabelUsing() returns, or #id. Visible when the list holds every workspace: a panel without a current tenant (the operator's view), or one with scopeToTenant(false). Toggleable. |
| Started | Relative time with the full date in the tooltip; a green badge while the recording is still receiving events. Sortable. |
| Length | Wall-clock length, for example 4m 12s. |
| Pages | Page views, wire:navigate swaps included. Sortable. |
| Errors | Uncaught errors plus failed requests; red when above zero. Sortable. |
| Rage clicks | Hidden by default, toggleable, sortable. |
| Vitals | Good, Needs improvement or Poor: the worst rating among the measured LCP, INP and CLS, with the values in the tooltip. |
| Device | Desktop, Tablet or Mobile. Toggleable. |
| First page | The entry URL without the scheme, full URL in the tooltip. Toggleable. |
| Pinned | A bookmark icon on pinned recordings. Toggleable. |
| Size | As stored (gzip). Hidden by default, sortable. |
Filters
| Filter | Name | What it does |
|---|---|---|
| Errors | errors |
Ternary: all, "With errors" (error_count > 0), "Without errors". |
| Slow pages (poor vitals) | poor_vitals |
Toggle. LCP above 4000 ms, INP above 500 ms or CLS above 0.25. |
| Rage clicks | rage_clicks |
Toggle. At least one rage click. |
| Impersonated | impersonated |
Toggle. Recordings made while someone was impersonating. |
| Pinned | pinned |
Toggle. |
| Device | device |
Select: Desktop, Tablet, Mobile. |
| First page contains | url |
Text. Matches part of the entry URL, for example invoices. |
| From / Until | started |
Two dates on started_at. |
The filter names are what you use in a URL or a test, for example ->filterTable('poor_vitals').
Row actions
Watch opens the watch page. Pin / Unpin keeps a recording out of pruning. Delete removes the recording, its chunks, its markers and its files on the disk. Delete is also available as a bulk action.
The watch page
The title is the person, the subheading the start time.
- The facts: length (with a badge while live), pages, errors, vitals (
LCP 1840 ms · INP 96 ms · CLS 0.020), device and viewport, size, who impersonated (when someone did), the first page (copyable), and a note when the recording reachedingest.max_session_mband was cut short. - The player: the core's
<x-session-replay::player>inside awire:ignorewrapper, so a Livewire re-render of the page leaves it alone. Play, speed (1x to 8x), skip inactivity and fullscreen. Markers are drawn on the timeline in their colour. - The marker list next to the player: one chip per kind of marker with its count (Page, Error, Request, Console, Rage click, Vital, Custom) to show or hide that kind, and the markers themselves with their time. A click seeks to a second before the moment. Vitals start hidden.
Adding ?t=83 to the URL opens the replay at 1:23, which is handy in a ticket. The marker kinds, their colours and the player's events are described in the core's Watching replays.
Header actions
| Action | What it does |
|---|---|
| Pin / Unpin | A pinned recording is never pruned. |
| Export | Downloads session-replay-{id}.json with everything needed to replay the recording elsewhere. See The export file. |
| Delete | Removes the recording and its files. |
The export file
| Key | What it holds |
|---|---|
session |
Who, where and when: id, user_type, user_id, tenant_type, tenant_id, entry_url, started_at, last_activity_at, properties |
markers |
type, label, payload and at_ms of every marker |
chunks |
The rrweb events as stored, one array per uploaded batch, in upload order |
snapshots |
The shared page snapshots the recording points at, keyed by their SHA-256: each one is a full snapshot's node tree. {} when the recording has none |
assets |
The stylesheets the events and the snapshots point at, keyed by their SHA-256 |
To play it with rrweb, put back what the recorder took out, in this order:
- Concatenate the chunks and sort the events by
timestamp. - A full snapshot event on a page with shared snapshots carries a hash instead of its tree:
{"type": 2, "data": {"srSnapshot": "<sha256>", "initialOffset": {...}}}. Setdata.nodetosnapshots[<sha256>]and removesrSnapshot. - A stylesheet stored once for every recording is a placeholder,
/*sr-asset:<sha256>*/, in a_cssTextattribute or in the text of a<style>element. Replace it withassets[<sha256>].
Files exported by 1.0.0-beta.1 of this plugin carry no snapshots key; read a missing key as {}. Pages such a file recorded with shared snapshots cannot be put back from it, so export them again.
Who may do what
The resource asks the same question as the core's viewer and its data routes: the viewSessionReplay gate, for the person signed in to the panel (Filament::auth()->user()).
| Ability | Without a policy |
|---|---|
| See the resource and the list | viewSessionReplay without a recording |
| Open a watch page | viewSessionReplay with the recording |
| Pin and unpin | Whoever may open that recording (the view policy ability when a policy is registered) |
| Delete, bulk delete | The deleteSessionReplay gate when your app defines it (with the recording for one, without for bulk); otherwise whoever may watch |
| Create, edit | Never |
use Illuminate\Support\Facades\Gate;
use Packstub\SessionReplay\Models\ReplaySession;
// Support watches customers, never colleagues.
Gate::define('viewSessionReplay', fn ($user, ?ReplaySession $session = null): bool
=> $user->is_staff && ! $session?->user?->is_staff);
// Only leads delete.
Gate::define('deleteSessionReplay', fn ($user, ?ReplaySession $session = null): bool
=> $user->is_lead);
Until viewSessionReplay is defined, only the local environment is let in, so the resource is hidden in production until you decide.
Pinning is part of watching: anyone who may open a recording may also pin it, and a pinned recording stays past retention.days until someone unpins or deletes it. Give watch access with that in mind, and look at the Pinned filter now and then to unpin what is no longer needed.
The gate is asked for the list as a whole. A rule that depends on the recording (like the one above) blocks the watch page and the data, while the row still appears in the list. To narrow the list too, scope the panel to a workspace (Tenancy) or extend the resource and override getEloquentQuery().
Rows the gate would refuse
The gate decides about one recording at a time, so by itself it cannot shorten a list. When yours narrows single recordings (support may watch customers, never other staff), tell the list the same rule as a query with the core's SessionReplay::visibleUsing():
use Illuminate\Database\Eloquent\Builder;
use Packstub\SessionReplay\Facades\SessionReplay;
SessionReplay::visibleUsing(function (Builder $query, $viewer): void {
$query->whereNotIn('user_id', Staff::query()->pluck('id')->map(fn ($id) => (string) $id));
});
The resource, a person's Session replays tab, the stats widget and the Watch last session action all start from it, with the person signed in to the panel as $viewer. Pages stay full and counts stay right because the rule runs in SQL. Opening a recording always asks the gate, with or without it.
Policies and Filament Shield
When a policy is registered for Packstub\SessionReplay\Models\ReplaySession, the resource follows Filament's usual policy checks instead of the gate: viewAny, view, delete and deleteAny. That is how Filament Shield permissions plug in: generate the policy for the resource and assign the permissions to roles.
use Illuminate\Support\Facades\Gate;
use Packstub\SessionReplay\Models\ReplaySession;
Gate::policy(ReplaySession::class, \App\Policies\ReplaySessionPolicy::class);
One thing to keep in step: the player loads the manifest, the chunks and the stylesheets from the core's routes, and those always ask the viewSessionReplay gate. With a policy in place, define the gate as well and let it delegate, so the page and the data agree:
Gate::define('viewSessionReplay', function ($user, ?ReplaySession $session = null): bool {
return $session === null
? $user->can('viewAny', ReplaySession::class)
: $user->can('view', $session);
});
Without the gate, a person the policy allows would open the watch page and see "You are not allowed to watch this recording" in the player. The export asks both, too.
In a multi-tenant panel, a generated policy checks a permission, not the workspace: the panel finds a recording through its scoped query, but the core's data routes take a recording id from the URL. Check the workspace in the policy's view ($session->tenant_id), as in Tenancy, so the data routes refuse another workspace's recording as well.