Skip to main content

Chat interaction events

The /chat surface — per-agent chips, questionnaires, attachments, ⌘K conversation search — shipped with no measurement at all. This page is the vocabulary that fixes that, and the rules it is held to.
This is not crash reporting. crewship telemetry on|off and the Telemetry guide are about anonymous Sentry crash reports, which leave your machine when you allow them. Chat interaction events never leave: lib/telemetry.ts has no network transport, and no fetch call exists anywhere in it. Same English word, different concern.

Why these are not journal entries

The journal is the obvious home — it is typed, durable, workspace-scoped and already mirrored into the frontend. It was rejected for three reasons.
  • The journal is written by the server, not the browser. The client can only GET /api/v1/journal. Emitting from the chat UI would mean opening an ingest endpoint so a browser can assert rows into the table the product audits through — the same table that holds credential reveals, keeper decisions and approvals. A chip being displayed is not an audited fact, and mixing the two devalues every claim the audit trail makes.
  • The volume is wrong. ask_chip_shown fires on impression. A journal row is durable, FTS-indexed, broadcast over SSE to every open activity feed and carried in crewship backup. Nobody wants “a chip was displayed” in the feed they read to find out what their crew did.
  • The cost per event is a product decision. Each new entry type touches internal/journal/types.go, lib/types/journal.ts, lib/journal-icons.ts and lib/activity-stream.ts, each with a parity test — and each needs an icon, a colour and a filter group, because those files describe a human surface. Thirteen funnel events would demand thirteen icons for something no operator will ever read one row of.
What was chosen instead is a client-side channel with one owner of the vocabulary (lib/telemetry.ts), a schema per event, and an in-memory buffer. The objection to a client analytics channel — “what does a message contain” — is answered structurally below rather than by policy.

The privacy rule, and why it is not a promise

PRIVACY.md says the install sends no usage analytics or product metrics. That is kept by construction: this module cannot make a network request. Within the payload, the guarantee is that a value can only be There is deliberately no free-string kind. A message, a form answer, a filename or a search query has no shape it can be expressed in, and emitChatEvent drops anything that does not fit rather than passing it through — undeclared keys included, because the sanitiser iterates the schema and never the input. The id charset excludes @. It was included in one draft and the whole-vocabulary sweep in lib/__tests__/telemetry.test.ts immediately pushed jana@example.com through an id field: an email has no whitespace and is well under 64 characters, so it is perfectly id-shaped. No identifier here needs an @.

What a privacy reviewer should check

  1. No transport. grep -n "fetch\|XMLHttpRequest\|sendBeacon" lib/telemetry.ts returns nothing. Any sink is registered by the host through setChatTelemetrySink, which is the single place a destination could ever be introduced.
  2. The vocabulary sweep. lib/__tests__/telemetry.test.ts, describe("privacy: no event can carry user content") — it poisons every declared key of every event with content-bearing strings, adds undeclared keys (message_text, file_name, answers), and asserts nothing recognisable reaches a sink. It runs over CHAT_EVENT_NAMES, so a new event is covered the moment it is declared.
  3. The key-name audit. The same file asserts that no payload key matches a content-shaped name (*_text, *_body, *_value, *_title, *_query, …).
  4. Per-surface leak tests. Each instrumented component has its own “carries no text into telemetry” block asserting the specific strings that surface handles: the chip label, the form answers, the filename, the search terms.
  5. The one author-supplied string. ask_form_abandoned.last_field_id is a field name from the form definition — the key the pack author wrote, e.g. supplier. It is never the value the user typed. It is the only author-controlled string in the vocabulary and it is id-validated.
  6. Question chips are fingerprinted, not carried. A suggested question has no row of its own, so chip_id is q_<fnv1a hex> of its text (hashedId). The text never reaches an event.
  7. The reader adds no shape. The dev-only console binding (below) returns copies of the same sanitised buffer and is absent from production builds; lib/__tests__/telemetry-dev-bridge.test.ts re-runs the whole-vocabulary sweep through it and asserts no transport is reached.

The vocabulary

Payload keys marked ? are optional.

Ask chips

chip_kind is the point of the pair. A question chip sends; a form chip opens. Folding them into one number answers nothing. Impressions are deduped per chip per page load (emitChatEventOnce) — React re-renders the rail on every keystroke in the composer below it, and without the dedupe the “shown” number would be a render count. Chips hidden behind +N do not count as shown until the catalogue is opened.

Ask forms

Exactly one terminal event per sheet, whichever of the five exits was taken (Cancel, the header ✕, Escape, the mobile scrim, or the sheet simply going away — which is navigated). A send the composer refused is not a submission: the sheet stays open with everything still in it, and counting it would inflate the completion rate the PRD sets a target on. last_field_id is where a bad form shows itself. An aggregate completion rate tells you a form is being abandoned; only this tells you everyone stops on the same question.

Attachments

reason is http_error, network, too_large, unsupported_type, rate_limited or unknown — a classification, never the server’s error body, which the composer already keeps out of the DOM because it can echo paths and driver errors. mime_kind is a coarse class (image, pdf, text, audio, archive, other) derived from the MIME type and never from the filename. A file refused by the 25 MB cap before it reached the network still emits attachment_upload_failed with reason: "too_large" — it is a failed attachment to the person holding the phone, and it is the one the server logs can never show.

Sessions

A session title is derived from the first message, so it is content and it is not recorded — only whether the title was derived or typed. auto is emitted by autoTitleSession after the server accepts the PATCH: a refused rename leaves the session untitled, and an event saying otherwise would disagree with the sidebar the user is looking at. Nothing emits manual yet — renaming by hand is a different surface. source names the door, and there are three: A create the server refused emits nothing, from any of the three: there is no conversation, and a funnel that counts refusals as starts is worse than no funnel, because it is believed. chip, palette and home are declared for doors that do not exist here yet. Nothing about the search terms is recorded — not the text, not its length. What somebody types into ⌘K is as private as the message they are hunting for. The two sources are two doors onto one question. The ⌘K palette asks the server about message bodies; the chat tree’s search box filters titles it already has. Somebody who searches the tree, finds nothing, and then reaches for ⌘K is telling you the tree’s scope is too narrow — which is only legible because both emit the same event under a different source. position is the rank of the row that was opened. A search whose answer is always at position 4 is a ranking bug, and nothing else in the product can see it.

Approvals in chat

Declared but not yet emitted

Two events have no call site. This is tracked mechanically in lib/__tests__/telemetry-call-sites.test.ts, which scans the UI source and fails if an event is neither wired nor on its pending list — and fails just as loudly if a pending event has quietly been wired without this table being updated. Both are declared so that whoever builds the in-chat approval card emits the agreed name rather than inventing a second one. Neither is a wiring to-do: there is nothing to wire them to.

Reading the events

The buffer used to be write-only. peekChatEvents() and drainChatEvents() were exports attached to nothing — no console, no CLI and no UI could read a single event, which is how the gap was found: somebody tried to demonstrate the funnel and had nowhere to look. Both people who need to read these are sitting at the machine that produced them: a developer demonstrating or debugging the funnel, and a maintainer asking whether a surface is used at all. Neither needs a request, so the reader is a development-only binding on window.
summary() is the one that answers “is this surface used at all”, and it lists every declared event rather than only the ones seen — otherwise never used and never declared look identical.
It is not in a production build. The binding is installed behind a bare process.env.NODE_ENV === "production" guard, which Next inlines at build time, so the branch — and the reader with it — is dropped from the production bundle. window.__CREWSHIP_CHAT_TELEMETRY__ is undefined there, not a disabled stub. lib/__tests__/telemetry-dev-bridge.test.ts asserts both the absence and the shape of the guard.
The reader does not weaken the privacy guarantee, because it reads the same sanitised buffer emitChatEvent writes and adds no shape of its own: the whole-vocabulary poison sweep is run a second time through the reader (describe("privacy: the reader is not a second way out")), and the same file asserts that emitting the entire vocabulary and reading it back makes no fetch, sendBeacon, XMLHttpRequest or WebSocket call, on top of a scan of the module’s own source for those names.

Where the events go

By default: an in-memory ring buffer, capped at 500 events, read with the dev binding above (or with peekChatEvents() / drainChatEvents() from a test). That is enough for a Playwright flow to assert a funnel end to end, and it is deliberately not enough to answer a 30-day question. To send them somewhere, register a sink once, high in the tree:
A sink that throws cannot break the interaction that produced the event — emitChatEvent catches everything and reports to the dev console only. That is asserted per surface: a throwing sink must still open the form, send the message and finish the upload.

Reading the ask-packs targets off these events

The ask-packs PRD sets four targets. Three are computable from this vocabulary today:
  • ≥ 35 % of sessions start from a chipask_chip_clicked grouped by session_id, over sessions. chat_session_created is wired now; what is still missing is chat-panel.tsx passing sessionId into AskRail, without which a chip click cannot be attributed to the session it started. Note that the pre-change baseline is gone: per-agent chips shipped before any event existed, so there is no generic-chip cohort left to compare against. Record the date measurement starts and compare cohorts after it, not before.
  • ≥ 70 % form completion once openedask_form_submitted over ask_form_opened, per template_id. ask_form_abandoned.last_field_id says which question to fix.
  • median photo → filed under 45 s on mobileattachment_uploaded duration_ms with source: "camera" is the upload half.
The fourth (≥ 60 % of proposals filed without a field edit) belongs to the intake pipeline and is not part of this vocabulary.