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. Exactly one is wired today:
A create the server refused emits nothing: there is no conversation, and a
funnel that counts refusals as starts is worse than no funnel, because it is
believed.
sidebar, deeplink, chip, palette and home are declared in
lib/telemetry.ts but nothing emits them. sidebar and deeplink are not
merely unbuilt — they were live on the surface the chat rewrite replaced, and
their emitters went with chat-page-client.tsx. So the funnel currently
under-counts: New conversation in the left column and an arrival with
?prompt= both mint sessions and neither is recorded. Read chat_session_created
as “sessions started by typing into the composer”, not as “sessions started”.
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 conversations column’s search box filters titles it
already has. Somebody who searches the column, finds nothing, and then reaches
for ⌘K is telling you that column’s scope is too narrow — which would only be
legible if both emitted the same event under a different
source.
Only palette emits today. The column’s search box filters in place and
records nothing, so the comparison above is a design the data does not yet
support; source is currently a constant. Its emitter was lost with the chat
surface rewrite, along with the test that pinned it.
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.