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_shownfires on impression. A journal row is durable, FTS-indexed, broadcast over SSE to every open activity feed and carried increwship 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.tsandlib/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.
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
- No transport.
grep -n "fetch\|XMLHttpRequest\|sendBeacon" lib/telemetry.tsreturns nothing. Any sink is registered by the host throughsetChatTelemetrySink, which is the single place a destination could ever be introduced. - 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 overCHAT_EVENT_NAMES, so a new event is covered the moment it is declared. - The key-name audit. The same file asserts that no payload key matches a
content-shaped name (
*_text,*_body,*_value,*_title,*_query, …). - 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.
- The one author-supplied string.
ask_form_abandoned.last_field_idis 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. - Question chips are fingerprinted, not carried. A suggested question has
no row of its own, so
chip_idisq_<fnv1a hex>of its text (hashedId). The text never reaches an event. - 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.tsre-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.
Conversation search
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 inlib/__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.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 withpeekChatEvents() / 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:
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 chip —
ask_chip_clickedgrouped bysession_id, over sessions.chat_session_createdis wired now; what is still missing ischat-panel.tsxpassingsessionIdintoAskRail, 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 opened —
ask_form_submittedoverask_form_opened, pertemplate_id.ask_form_abandoned.last_field_idsays which question to fix. - median photo → filed under 45 s on mobile —
attachment_uploadedduration_mswithsource: "camera"is the upload half.
≥ 60 % of proposals filed without a field edit) belongs to the
intake pipeline and is not part of this vocabulary.