Skip to main content

Browser spans tell you how long a screen took and whether it errored. They do not tell you whether it was usable: which screens are slow to become interactive, where people back out, which forms get abandoned, which error states are actually reached. POST /api/v1/ui-events is the endpoint behind that stream.

Events land in the ui_events ClickHouse table next to request_logs — same deployment, same retention, same redaction — and join against gateway traffic on trace_id.

Two properties worth knowing#

Note

Events are structural only. Every accepted field is a key, an enum, a duration or an id. There is no field a form value, prompt or free-text body could travel in. target carries the name of a form, control or validation rule — never what was typed into it. The schema is the guarantee, rather than a policy applied on write.

Note

Attribution is server-side. user_id is filled from the authenticated session and is not read from the request body, so a caller cannot file events against somebody else. Do not send it.

POST /api/v1/ui-events#

Authenticated by a live dashboard session (a bearer session token), not the admin token. Any logged-in account may file its own events.

Returns 202 Accepted.

Batching#

Browsers batch these — a visibilitychange beacon flushes whatever queued since the last one — so a batch is the normal shape rather than an optimization. At most 100 events per request; a larger batch is rejected with 400.

Fields#

FieldRequiredNotes
event_idyesclient-generated id, ≤ 96 chars
screenyesstable screen key (a route id, or shell), not a URL
actionyessee the enum below
outcomenook, error or cancelled; defaults to ok
targetnoname of the form, control or validation rule
from_screennoprevious screen, so navigation paths are queryable
duration_msnotime-to-interactive, form dwell, or save-to-confirmation
trace_idnojoins to the gateway request this caused
session_idnogroups a single browsing session
org_id / team_id / project_idnogrouping labels
app_versionnodashboard build

action is one of screen_view, time_to_interactive, navigate, back_out, form_submit, form_abandon, validation_error, empty_state, error_state, save_confirmed, retry_submit, refused_click, abandon_dirty.

Warning

screen, target and from_screen are LowCardinality ClickHouse columns and are capped at 96 characters. Send a stable key such as models, not a URL — a URL carries ids and query parameters, which turns a key column into an unbounded one and degrades the table.

Reserved screen key#

screen is a route id, with one exception: shell names the dashboard’s own chrome rather than a screen. The scope switcher in the sidebar raises its delete confirmations and create dialogs outside any screen, so their form_submit, save_confirmed and form_abandon rows carry screen = shell and a target such as org-delete or team-create. No route uses the id, so filtering on it leaves out every screen.

What the dashboard sends#

You do not have to call this endpoint to get data — the bundled dashboard emits against it already.

Navigation is instrumented once in the app shell, so screen_view, navigate and back_out cover every screen without per-screen wiring. back_out is recorded when the user leaves by pressing the browser’s back button rather than by acting on the screen, and carries how long they had been there: backing out after eight seconds is a navigation mistake, backing out after four minutes is a dead end.

empty_state is emitted by the shared placeholder component, so every zero-data screen reports itself. error_state is emitted the same way, by the shared alert a screen shows when a read fails, once each time it appears.

Three actions record what the operator could not do. retry_submit is a save attempted again after one failed — the moment somebody did not understand the first failure. abandon_dirty is a form closed with a draft in it, as against form_abandon, which is now only a form closed untouched: giving up and misclicking are different findings. refused_click is a reach for a control your role denies; its target is the control key joined to the capability that refused it, such as provider-new:provider:create, and it carries no label and no message — the row says which permission boundary is in somebody’s way, not what the control said or who pressed it.

time_to_interactive and the form lifecycle (form_submit, form_abandon, abandon_dirty, retry_submit, validation_error, save_confirmed) are per-screen, because only the screen knows which of its several requests is the one the user is actually waiting for. Screens are being wired progressively; an unwired screen is silent for those actions rather than wrong.

form_abandon counts both ways an editor can go away without a save: the panel closing, and the screen dropping it altogether. duration_ms carries the dwell, which is what separates a form opened by mistake from one somebody filled in and gave up on. It is emitted at most once per opening, and never for a form that was submitted or one that was never opened.

Events are queued in the browser and flushed every few seconds, when a full batch accumulates, and when the tab is hidden. If the endpoint answers 401, 403, 404 or 405 the dashboard stops sending for the life of the tab, so an older control plane or a proxy that drops the route costs one request rather than one per flush.

If the control plane cannot store a batch — ClickHouse is down, or the ui_events table is missing — the endpoint answers 500 with a generic message and the batch is dropped; the dashboard keeps sending, so capture resumes by itself once the store is back. The store’s own error never reaches the browser. It goes to the control-plane log instead, as a telemetry ingest failed warning at most once a minute per stream (with a count of the failures it stands for), and every failure adds one to the rolter_control_ingest_failures metric, so a lost stream can be alerted on rather than discovered.

Turning it off#

Two things stop capture, and only one of them is meant to:

  • No clickhouse_url — there is nowhere to write, so every batch is answered 500 and dropped. It is not a quiet off switch: the control plane logs a rate-limited warning and counts each one in rolter_control_ingest_failures (reason=unconfigured). Use the switch below to turn capture off on purpose.
  • The Dashboard usage events switch on Observability → Logs Settings — a deployment-level opt-out, also settable as ui_events through PUT /api/v1/logging-settings. The endpoint still answers 202, and stores nothing. It takes effect on the next batch, with no restart.

A control plane without a database reads the same opt-out from rolter.toml, and rolter-seed --import copies it into the database when the file sets it:

It is on by default, unlike logging.payload_capture, precisely because the schema cannot carry sensitive content.