# 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](https://packstub.dev/docs/filament-session-replay/people#naming-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](https://packstub.dev/docs/filament-session-replay/people#finding-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 reached `ingest.max_session_mb` and was cut short.
- **The player**: the core's `<x-session-replay::player>` inside a `wire:ignore` wrapper, 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](https://github.com/packstub/session-replay/blob/main/docs/watching.md#the-player).

### 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](#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:

1. Concatenate the chunks and sort the events by `timestamp`.
2. A full snapshot event on a page with [shared snapshots](https://github.com/packstub/session-replay/blob/main/docs/storage.md#shared-snapshots) carries a hash instead of its tree: `{"type": 2, "data": {"srSnapshot": "<sha256>", "initialOffset": {...}}}`. Set `data.node` to `snapshots[<sha256>]` and remove `srSnapshot`.
3. A stylesheet stored once for every recording is a placeholder, `/*sr-asset:<sha256>*/`, in a `_cssText` attribute or in the text of a `<style>` element. Replace it with `assets[<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 |

```php
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](https://packstub.dev/docs/filament-session-replay/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()`:

```php
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](https://github.com/bezhanSalleh/filament-shield) permissions plug in: generate the policy for the resource and assign the permissions to roles.

```php
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:

```php
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](https://packstub.dev/docs/filament-session-replay/tenancy), so the data routes refuse another workspace's recording as well.
