Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dashboard UX telemetry as a structural-only event stream

Status: Accepted · Date: 6 Aug 2026 · Issues: #805 Relates: ADR-0023 (access-policy propagation)

Context

Browser tracing (#807) tells us how long a dashboard screen took and whether it threw. It does not tell us whether the screen was usable: which screens are slow to become interactive, where people back out, which forms get abandoned, which error and empty states are actually reached. Those are the questions that decide what to build next, and spans cannot answer them.

Collecting that is ordinarily where a product-analytics vendor is added. For a self-hosted gateway that sits in the request path of its operators’ LLM traffic, a second data processor is a hard sell: a different retention policy, a different redaction story, a different jurisdiction, and an outbound connection operators did not ask for. Several rolter deployments are air-gapped, where it simply does not work at all.

There is also a real hazard specific to this kind of telemetry. UX event streams are where free text leaks. An “abandoned form” event is one careless field away from carrying what was typed into the form, and the dashboard’s forms hold provider API keys, virtual keys and prompts.

Decision

Collect UX events in-house, into a ui_events ClickHouse table beside request_logs, and make the schema the privacy guarantee.

Structural only. Every column is a key, an enum, a duration or an id. There is deliberately no column a form value, prompt or free-text body could be written into. target names the form, control or validation rule; it never carries what failed the rule. A future column that could hold free text is a change to this ADR, not a routine migration.

This is chosen over the usual alternative — accept arbitrary payloads and redact on write — because redaction is a policy that has to keep being correct as the dashboard grows, while a schema with nowhere to put a value is correct by construction. logging.payload_capture already exists for the case where someone genuinely wants raw bodies, and it is off by default; this stream is deliberately not that.

Server-side attribution. user_id is taken from the authenticated session and a client-supplied one is ignored, so a caller cannot file events against another user.

Authentication, not authorization. The ingest endpoint is guarded by CurrentUser alone, following me.rs, and has no row in the capability table. The RBAC model holds that a viewer writes nothing — rbac_matrix.rs asserts exactly that — and a ui_event:create capability granted to any authenticated caller breaks the invariant. A viewer who could not file their own screen views would be absent from every funnel, which makes the data quietly wrong rather than absent. Keeping this off the capability table states plainly that it is not an operator surface.

On by default, with two off switches. Absent clickhouse_url the stream is inert. logging.ui_events = false is a deployment-level opt-out. It defaults on — unlike payload_capture — precisely because the schema cannot carry sensitive content, so the usual reason to make telemetry opt-in does not apply.

Consequences

Operators get usability data with no new vendor, no new egress and no new retention policy: same deployment, same TTL, same 90-day partitioning as request logs. Because events carry trace_id, a slow screen and the gateway request behind it are one join rather than two systems.

The cost is expressiveness. Any question that needs a value rather than a key is unanswerable by design, and some genuinely useful analyses are foreclosed. That is the intended trade: the class of incident this prevents — dashboard secrets in an analytics table — is worse than the analyses it forbids.

Adding a screen means adding a stable screen key rather than letting a URL through. screen, target and from_screen are LowCardinality columns capped at 96 characters, which a real URL does not fit; sending one would degrade the table, so the bound fails the write instead.

Per-tenant routing of this stream is out of scope and tracked separately (#812).