CareerVector Analytics Event Catalog
Status: CANONICAL. This document is the contract for all telemetry emitted by CareerVector. Every event type must appear here before shipping. Adding a new event without updating this catalog is a merge-blocking defect.
Audience: Contributors adding observability, engineers writing queries, and anyone auditing privacy posture.
Implementation references:
- Client yellow tier:
ui/src/lib/telemetry/yellow.ts - Client red tier:
ui/src/lib/telemetry/red.ts - Server ingest:
api/src/routers/telemetry.ts(POST /telemetry/event) - P2P-specific ingest:
api/src/routers/telemetry.ts(POST /telemetry/p2p) - Architecture:
REALTIME-ARCHITECTURE-V3.md§5.2
1. Schema Overview
Egress: off by default. Browser telemetry no-ops unless the bundle is built
with VITE_AXIOM_DATASET and VITE_AXIOM_INGEST_TOKEN. Controlled diagnostic
cohorts push directly from the browser to Axiom's EU Frankfurt regional ingest
endpoint — no server proxy, no CF Worker relay.
Known datasets for explicit diagnostic rollouts:
| Environment | Dataset | Region |
|---|---|---|
| Production | cv_telemetry |
EU Frankfurt (eu-central-1.aws) |
| Staging / dev | cv_telemetry_dev |
EU Frankfurt (eu-central-1.aws) |
Ingest URL gotcha: The correct path shape is /v1/ingest/{dataset} (on the edge URL).
The control-plane path /v1/datasets/{dataset}/ingest is NOT used for the regional edge —
it returns 404. Always use https://eu-central-1.aws.edge.axiom.co/v1/ingest/{dataset}.
Axiom event format
Events are JSON objects posted as a JSON array (one HTTP call per flush batch). Every event
includes a tier field ('yellow'/'red') identifying which bundle emitted it.
There is no separate env field — the dataset boundary (production vs dev) carries that.
Our event field convention
Each event object follows this structure:
| Field | Type | Content |
|---|---|---|
type |
string | Event type string (matches the catalog name, e.g. op_applied) |
tier |
string | Emitting tier: 'yellow' / 'red' |
ts |
number | Client-side unix timestamp (ms) |
workspaceId |
string | Workspace ID (yellow/red only) |
peerId |
string | Peer ID (yellow/red only) |
dailyToken |
string | Non-reversible daily hash (diagnostic heartbeat only) |
tierBucket |
string | Configured tier bucket (heartbeat only: 'yellow'/'red') |
| (event-specific fields) | various | Documented per event type below |
All field names are literal JSON keys — Axiom stores the raw JSON object. Query via Axiom APL (see §4).
Fields beyond the convention are documented per event type where they deviate.
2. Per-Tier Event Types
Green is silent and loads no telemetry module. Yellow and red are explicit diagnostic tiers; red is a superset of yellow. Server-side enforcement rejects tier-mismatched events as a second defense layer.
2.1 Green Tier — Silent
Green is the default target for public/free users. It emits no events, creates no telemetry localStorage keys, and makes no telemetry network requests.
Event count: 0
aggregate_heartbeat
| Attribute | Value |
|---|---|
| Tier required | Yellow or red diagnostic cohorts only |
| Emitter | yellow.ts / red.ts → initAggregate() |
| Frequency | At most once per workspace per calendar day (localStorage dedup key cv_tele_agg_<id>) |
| When fired | On workspace init, after the 30-char daily token is derived |
Data point layout:
| Slot | Content | Example |
|---|---|---|
blobs[0] |
'aggregate_heartbeat' |
aggregate_heartbeat |
blobs[1] |
Daily token — SHA-256(workspaceId:YYYY-MM-DD), first 32 hex chars. Non-reversible. |
a3f1c2... |
blobs[2] |
Tier bucket string | green / yellow / red |
doubles[0] |
Client-side unix timestamp (ms) | 1714000000000 |
indexes[0] |
Tier bucket | green |
What a bug looks like here: A sudden drop in daily unique tokens for a given tier bucket indicates users are not reaching workspace init (JS crash before init, broken network path to ingest endpoint, or dedup key pollution). A spike in a specific tier bucket indicates unexpected tier assignment — e.g. all new workspaces landing in red when they should default to green at GA.
2.2 Yellow Tier — Support Event Stream
Yellow is for testers and early adopters. workspace_id IS transmitted. Events give full op-level visibility to answer "what did the user do and what broke."
Event count: 18
Rate limits: 10 events/second per type, ring buffer of 200 events max, flushed every 30 s or on visibilitychange:hidden.
tier_transition
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitTierTransition(from, to) |
| When fired | When the workspace diagnostic tier changes at runtime |
| Slot | Content |
|---|---|
blobs[0] |
'tier_transition' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
New tier string (to argument) |
doubles[0] |
0 |
doubles[2] |
Timestamp (ms) |
| Payload field | fromTier — previous tier (not mapped to a blob slot; visible in raw JSON before server normalization) |
Bug pattern: Unexpected tier transitions in production indicate the tier-gate UI was triggered accidentally, or the settings migration logic is assigning wrong tier values on workspace creation.
webrtc_failure
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitWebRTCFailure(reason) |
| When fired | When WebRTC ICE negotiation fails (STUN timeout, TURN unavailable, etc.) |
| Slot | Content |
|---|---|
blobs[0] |
'webrtc_failure' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Failure reason string (truncated to 200 chars) |
doubles[2] |
Timestamp (ms) |
Bug pattern: A sudden spike or sustained elevation in webrtc_failure rate, especially correlated across multiple workspaces or geos, indicates a STUN/TURN regression. Cross-reference with p2p_tier events: if T1 rate drops and webrtc_failure rises simultaneously, the STUN server configuration or ICE candidate gathering is broken.
cold_load_latency
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitColdLoadLatency(ms) |
| When fired | After a cold workspace load completes (new device, first visit, cache miss) |
| Slot | Content |
|---|---|
blobs[0] |
'cold_load_latency' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[0] |
Load latency in ms (rounded) |
doubles[2] |
Timestamp (ms) |
Bug pattern: p99 climbs above 3 s indicates D1 snapshot fetch is regressing. If p50 is stable but p99 spikes, suspect timeout or retry logic. Flat histogram at a suspiciously round number (e.g. exactly 30000 ms) indicates the client hit a timeout sentinel.
connection_error
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitConnectionError(msg) |
| When fired | When the sync layer encounters a non-fatal but observable error (WebSocket close, DO unavailable, etc.) |
| Slot | Content |
|---|---|
blobs[0] |
'connection_error' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Error message (truncated to 300 chars) |
doubles[2] |
Timestamp (ms) |
Bug pattern: Sustained rate of connection_error without matching sync_connected follow-ups means the reconnect loop is failing. If the message string cluster around a specific phrase (e.g. "WebSocket is closed" or "ice failed"), that phrase is the regression surface.
sync_connected
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitSyncConnected(tier) |
| When fired | When a peer successfully establishes sync on any transport (P2P or DO relay) |
| Slot | Content |
|---|---|
blobs[0] |
'sync_connected' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Transport tier string (e.g. T1, T2, T3) |
doubles[2] |
Timestamp (ms) |
Bug pattern: If sync_connected stops appearing for a workspace that was previously active, the peer lost connectivity and did not recover. Absence of sync_connected across a fleet of peers simultaneously indicates an infrastructure outage (DO worker, D1, or TURN service).
sync_disconnected
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitSyncDisconnected(reason?) |
| When fired | When a peer loses its sync connection |
| Slot | Content |
|---|---|
blobs[0] |
'sync_disconnected' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Disconnect reason (optional, truncated to 100 chars) |
doubles[2] |
Timestamp (ms) |
Bug pattern: High sync_disconnected rate with no matching sync_connected recovery indicates the reconnect logic is broken or the server is rejecting reconnects. Pair with connection_error to distinguish client-side failures from server-side rejections.
op_applied
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitOpApplied(opType, latencyMs, outcome) |
| When fired | After each Yjs op is applied locally — the core user-activity signal |
| Slot | Content |
|---|---|
blobs[0] |
'op_applied' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Op type string (e.g. 'insertSection', truncated to 80 chars) |
doubles[0] |
Apply latency in ms (rounded) |
doubles[2] |
Timestamp (ms) |
outcome field |
'ok' / 'error' / 'rejected' (not a blob slot; in raw JSON) |
Bug pattern: Op rate flatlines (near-zero op_applied rate for an active workspace) indicates sync is broken — ops are not flowing through. A spike in outcome='error' or outcome='rejected' indicates a CRDT schema validation regression. p99 apply latency climbing above 100 ms on a well-connected client indicates a Yjs GC or IndexedDB write bottleneck.
agent_tool_call
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitAgentToolCall(toolName, outcome, latencyMs) |
| When fired | When an MCP agent invokes a tool on the workspace |
| Slot | Content |
|---|---|
blobs[0] |
'agent_tool_call' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Tool name (truncated to 80 chars) |
doubles[0] |
Call latency in ms (rounded) |
doubles[2] |
Timestamp (ms) |
outcome field |
'ok' / 'error' |
Bug pattern: A sustained outcome='error' rate for a specific tool name indicates that tool's implementation has regressed. High latency on agent_tool_call indicates the agent is blocked on slow workspace state resolution.
ai_op_result
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitAiOpResult(providerId, tokenCount, outcome) |
| When fired | After an AI provider returns a result for an LLM operation |
| Slot | Content |
|---|---|
blobs[0] |
'ai_op_result' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Provider ID (truncated to 40 chars, e.g. groq, mistral) |
doubles[0] |
Token count |
doubles[2] |
Timestamp (ms) |
outcome field |
'ok' / 'error' |
Bug pattern: A spike in outcome='error' for a specific provider indicates an API key expiry, provider outage, or cascade misconfiguration. High and growing token counts for a fixed operation type indicate prompt bloat.
api_call
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitApiCall(endpoint, statusCode, latencyMs) |
| When fired | After each server API call completes |
| Slot | Content |
|---|---|
blobs[0] |
'api_call' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Endpoint pattern (truncated to 120 chars, e.g. /api/workspaces) |
doubles[0] |
Latency in ms (rounded) |
doubles[2] |
Timestamp (ms) |
statusCode field |
HTTP status code |
Bug pattern: p99 latency climbing for a specific endpoint indicates a D1 query regression or Worker CPU budget issue. A spike in statusCode=5xx for any endpoint indicates a server error; cross-reference with api_error events for the error message.
api_error
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitApiError(endpoint, statusCode, msg) |
| When fired | When a server API call returns a non-2xx status |
| Slot | Content |
|---|---|
blobs[0] |
'api_error' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Endpoint pattern (truncated to 120 chars) |
doubles[0] |
Status code (cast to double, e.g. 404.0) |
doubles[2] |
Timestamp (ms) |
reason field |
Error message (truncated to 200 chars) |
Bug pattern: A sudden appearance of 404 errors on a previously stable endpoint indicates a route removal or rename regression. 429 errors indicate a client-side rate limit being hit; check the cascade retry logic.
page_navigate
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitPageNavigate(route) |
| When fired | On SvelteKit navigation (route change) |
| Slot | Content |
|---|---|
blobs[0] |
'page_navigate' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Route string (truncated to 80 chars) |
doubles[2] |
Timestamp (ms) |
Bug pattern: If page_navigate events stop appearing for an active session, client-side routing has broken. A route that was previously frequently visited disappearing from the distribution indicates a navigation regression — links removed, route renamed, or redirect loop.
modal_open
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitModalOpen(modalId) |
| When fired | When a modal dialog is opened |
| Slot | Content |
|---|---|
blobs[0] |
'modal_open' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Modal ID string (truncated to 80 chars) |
doubles[2] |
Timestamp (ms) |
Bug pattern: modal_open events appearing without a matching modal_close within a reasonable window indicate the close handler is broken or the modal is not rendering (the user can't close it). A modal ID that appears in modal_open events but never in render-success metrics indicates the modal component crashed before mounting.
modal_close
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitModalClose(modalId) |
| When fired | When a modal dialog is closed |
| Slot | Content |
|---|---|
blobs[0] |
'modal_close' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Modal ID string (truncated to 80 chars) |
doubles[2] |
Timestamp (ms) |
js_error
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | Auto-wired via window.onerror and window.onunhandledrejection in initSupport() |
| When fired | On any uncaught JS exception or unhandled Promise rejection |
| Slot | Content |
|---|---|
blobs[0] |
'js_error' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Error message string — ErrorType: message format (truncated to 300 chars) |
doubles[2] |
Timestamp (ms) |
Bug pattern: Any non-zero rate of js_error after a deploy warrants investigation. A spike in a new error message clustered immediately after a deploy SHA indicates a JavaScript regression introduced by that deploy. Cross-reference with op_applied rate: if ops flatlined at the same time an error spike started, the error is in the sync/apply path.
workspace_state_change
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitWorkspaceStateChange(snapshot) |
| When fired | When workspace structural state changes (job count, phase distribution, etc.) |
| Slot | Content |
|---|---|
blobs[0] |
'workspace_state_change' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[2] |
Timestamp (ms) |
Note: The stateSnapshot payload carries workspace metadata (job counts, phase distribution) as JSON. This is NOT mapped to blobs/doubles by the server normalizer — it is visible in the raw event JSON before server-side ingestion flattens it. The snapshot must never include job titles, company names, or any CV/CL content.
settings_change
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitSettingsChange(category) |
| When fired | When workspace settings are changed |
| Slot | Content |
|---|---|
blobs[0] |
'settings_change' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Settings category string (truncated to 80 chars) |
doubles[2] |
Timestamp (ms) |
perf_marker
| Attribute | Value |
|---|---|
| Tier required | Yellow |
| Emitter | emitPerfMarker(name, durationMs) |
| When fired | At named performance checkpoints (Typst compile time, snapshot load, etc.) |
| Slot | Content |
|---|---|
blobs[0] |
'perf_marker' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Marker name (truncated to 80 chars) |
doubles[0] |
Duration in ms (rounded) |
doubles[2] |
Timestamp (ms) |
Bug pattern: p99 of a named marker (e.g. typst_compile) climbing indicates a Typst WASM or document complexity regression. A marker that previously appeared regularly disappearing indicates the code path is no longer being reached.
2.3 Red Tier — Verbose Session Replay
Red is for internal development only. All yellow events are included. Red adds session-replay-grade input capture and Yjs wire-level instrumentation.
Rate limits: 20 events/second total (token bucket), flushed every 1 s.
Additional event count: 11 (on top of yellow's 18)
op_round_trip
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | emitOpRTT(rttMs) |
| When fired | After each Yjs op round-trip completes (send → remote ack → local apply) |
| Slot | Content |
|---|---|
blobs[0] |
'op_round_trip' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[0] |
Round-trip time in ms (rounded) |
doubles[2] |
Timestamp (ms) |
bytes_sent
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | emitBytesSent(bytes) |
| When fired | After each Yjs update frame is sent over the wire |
| Slot | Content |
|---|---|
blobs[0] |
'bytes_sent' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[0] |
Byte count for this frame |
doubles[2] |
Timestamp (ms) |
bytes_received
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | emitBytesReceived(bytes) |
| When fired | After each Yjs update frame is received |
| Slot | Content |
|---|---|
blobs[0] |
'bytes_received' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[0] |
Byte count for this frame |
doubles[2] |
Timestamp (ms) |
sync_step
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | emitSyncStep(step) |
| When fired | When the Yjs sync protocol emits a step-1 or step-2 sync message |
| Slot | Content |
|---|---|
blobs[0] |
'sync_step' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[0] |
Step number (1 or 2) |
doubles[2] |
Timestamp (ms) |
Bug pattern: Repeated step-1/step-2 cycles without settling indicates a convergence loop — two peers are continuously re-syncing without reaching identical state. This points to a Yjs document corruption or a mismatch in Y.Doc provider initialization.
awareness_update
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | emitAwarenessUpdate(bytes) |
| When fired | When a Yjs Awareness protocol frame is sent or received |
| Slot | Content |
|---|---|
blobs[0] |
'awareness_update' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[1] |
Awareness frame size in bytes |
doubles[2] |
Timestamp (ms) |
keydown
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | Auto-wired via window.addEventListener('keydown') in registerDomListeners() |
| When fired | On every keydown event in the window |
| Slot | Content |
|---|---|
blobs[0] |
'keydown' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
blobs[3] |
Key name — 'char' for printable characters (content never captured), or the key name for non-printable keys (e.g. Enter, Backspace, ArrowUp, truncated to 30 chars) |
doubles[2] |
Timestamp (ms) |
targetType field |
HTML element tag name of the event target (lowercase) |
Privacy note: Single-character keypresses are replaced with the literal string 'char'. The actual character is never stored. Non-printable key names (navigation, modifiers, function keys) are stored as-is.
mouseup
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | Auto-wired via window.addEventListener('mouseup') |
| When fired | On every mouseup event |
| Slot | Content |
|---|---|
blobs[0] |
'mouseup' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[2] |
Timestamp (ms) |
targetType field |
HTML element tag name (lowercase) |
targetId field |
data-testid attribute or element id (truncated to 80 chars, empty string if absent) |
Privacy note: Mouse coordinates are never captured. Only the element type and test ID are recorded, which identify UI element intent without revealing where on screen the user clicked.
scroll
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | Auto-wired via window.addEventListener('scroll', ..., { capture: true }) |
| When fired | On scroll events, throttled to at most 1 per second |
| Slot | Content |
|---|---|
blobs[0] |
'scroll' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[2] |
Timestamp (ms) |
hover
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | Auto-wired via window.addEventListener('mouseover') |
| When fired | On mouseover events, throttled to at most 2 per second |
| Slot | Content |
|---|---|
blobs[0] |
'hover' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[2] |
Timestamp (ms) |
targetType field |
HTML element tag name (lowercase) |
targetId field |
data-testid or element id (truncated to 80 chars) |
clipboard_event
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | Patched navigator.clipboard.readText and writeText in registerDomListeners() |
| When fired | When the app reads from or writes to the clipboard via the Clipboard API |
| Slot | Content |
|---|---|
blobs[0] |
'clipboard_event' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[2] |
Timestamp (ms) |
clipboardAction field |
'read' or 'write' |
Privacy note: Clipboard contents are never captured. Only the direction (read or write) is recorded.
dom_snapshot
| Attribute | Value |
|---|---|
| Tier required | Red |
| Emitter | Manual call site in error recovery logic |
| When fired | On error, to capture the serialized active view state for debugging |
| Slot | Content |
|---|---|
blobs[0] |
'dom_snapshot' |
blobs[1] |
workspace_id |
blobs[2] |
peer_id |
doubles[2] |
Timestamp (ms) |
domSnapshot field |
Compressed JSON string of the active view DOM state |
2.4 P2P Tier Event — Dedicated Endpoint
This event is sent to /api/telemetry/p2p rather than /api/telemetry/event. No tier gate is applied (all workspaces report P2P tier data). The event feeds the regression alert model for STUN/TURN coverage.
Event count: 1
p2p_tier
| Attribute | Value |
|---|---|
| Tier required | None — all workspaces |
| Emitter | reportTierTelemetry() in connection-impl.ts |
| When fired | Once per minute per peer while connected (fire-and-forget from leader tab) |
| Slot | Content |
|---|---|
blobs[0] |
'p2p_tier' |
blobs[1] |
Workspace ID (wsId) |
blobs[2] |
Peer ID |
blobs[3] |
Transport tier: 'T1' / 'T2' / 'T3' |
blobs[4] |
TURN availability: 'turn_available' or 'stun_only' |
doubles[0] |
Round-trip latency in ms (rounded) |
doubles[1] |
Byte rate inbound (bytes/s, rounded) |
doubles[2] |
Byte rate outbound (bytes/s, rounded) |
indexes[0] |
Transport tier — enables AE dimension filtering on T1/T2/T3 |
Bug pattern: T1 success rate dropping 10 %+ across the fleet (relative to baseline) indicates a STUN server misconfiguration, ICE candidate gathering regression, or a browser update that changed WebRTC behaviour. The turn_available vs stun_only split distinguishes whether T2 ICE config was offered; if stun_only rises without a corresponding T2 config change, the TURN credential endpoint may be returning errors.
3. Bug-Revealing Patterns
Concrete playbooks for common regression types. Each pattern names the query anchor event, what signal to watch, and how to distinguish root causes.
3.1 STUN/TURN Coverage Regression
Signal: T1 rate in p2p_tier drops 10 %+ over a rolling 24 h window. T3 rate rises correspondingly.
Distinguish root cause:
- If
turn_available='stun_only'rate is stable but T1 success drops → STUN servers are returning bad ICE candidates or timing out. - If
turn_available='stun_only'rate rises → the TURN credential endpoint is failing; T2 ICE config not being offered. - If
webrtc_failurespikes at the same time → ICE negotiation is failing outright, not just falling back.
Check: p2p_tier events grouped by blobs[3] (tier) and blobs[4] (TURN label), 24 h rolling.
3.2 JavaScript Regression
Signal: js_error rate spikes above baseline. Correlate with deploy timestamp.
Distinguish root cause:
- Cluster
blobs[3](error message) to find the dominant error string. - If a single new error message dominates → that error is the regression surface; trace to the component by the error type prefix.
- If
op_appliedrate drops simultaneously → the error is in the sync/apply path; severity is high. - If
op_appliedis unaffected → the error is in a background or UI path; lower priority.
3.3 Op Rate Flatline (Sync Broken)
Signal: op_applied event rate drops to near-zero for a workspace or fleet that was previously active.
Distinguish root cause:
- If
sync_connectedevents also stopped → the peer lost its connection and did not reconnect. - If
sync_connectedevents are present but ops are absent → the connection is live but updates are not flowing (e.g. Yjs provider not forwarding ops to the sync layer). - If
js_errorevents preceded the flatline → the error broke the apply path.
3.4 Modal Render Regression
Signal: modal_open events appearing for a given modalId, with no subsequent modal_close within 60 s AND no corresponding UI-success metric.
Distinguish root cause:
- If
js_errorevents appear within 1 s ofmodal_open→ the modal component threw on mount. - If no errors appear → the modal rendered but the close path is broken (close button missing, event handler unregistered, keyboard trap).
- Compare pre/post deploy: if the pattern only appears after a specific deploy, that deploy introduced the regression.
3.5 Cold-Load Latency Regression (Perf)
Signal: p99 of doubles[0] in cold_load_latency events climbs above 3 s.
Distinguish root cause:
- If p50 is stable but p99 climbs → tail-latency regression in D1 snapshot reads (query plan degradation or snapshot size explosion).
- If both p50 and p99 climb → the entire cold-load path has slowed (Worker startup time, D1 regional latency, snapshot decompression overhead).
- Cross-reference
perf_markerevents forsnapshot_decompressandydoc_applymarkers if instrumented.
3.6 API Failure Spike
Signal: api_error rate rises for a specific endpoint pattern.
Distinguish root cause:
statusCode=404→ route removed or renamed.statusCode=429→ client hitting rate limits; check cascade retry logic.statusCode=500or503→ server-side error; check Worker logs and D1 health.statusCode=401or403→ auth/access regression (should not occur in a no-auth app; indicates a misconfigured middleware).
4. Axiom APL Query Examples
Events land in Axiom. Query via APL (Axiom Processing Language) in the Axiom web UI or CLI.
Dataset names are referenced in APL as ['cv_telemetry'] (production) or
['cv_telemetry_dev'] (staging). All field names are the original JSON keys from the emitted
event object.
CLI usage: axiom query "<apl>" --start-time -24h (authenticated as careervector).
4.1 Daily Unique Workspace Heartbeats by Tier
['cv_telemetry']
| where type == 'aggregate_heartbeat'
| summarize heartbeats = count(), unique_tokens = dcount(dailyToken) by tier = tierBucket, bin(_time, 1d)
| sort by _time desc
4.2 WebRTC Tier Distribution (Last 24 Hours)
['cv_telemetry']
| where type == 'p2p_tier'
| summarize peer_reports = count(), avg_latency_ms = avg(latencyMs), p99_latency_ms = percentile(latencyMs, 99)
by transport_tier = tier, turn_label
4.3 WebRTC Tier Distribution Shift (Regression Alert)
['cv_telemetry']
| where type == 'p2p_tier'
| summarize count() by transport_tier = tier, bin(_time, 1h)
| sort by _time desc
Compare T1 count as a fraction of total per hour. A drop from baseline of more than 10
percentage points is a regression trigger.
4.4 JS Error Rate by Message (Last 48 Hours)
['cv_telemetry']
| where type == 'js_error'
| where _time > ago(48h)
| summarize occurrences = count(), affected_workspaces = dcount(workspaceId) by errorMsg
| sort by occurrences desc
| limit 20
4.5 Op Applied Rate and Outcome Distribution
['cv_telemetry']
| where type == 'op_applied'
| where _time > ago(3d)
| summarize total_ops = count(), avg_latency_ms = avg(latencyMs), p99_latency_ms = percentile(latencyMs, 99)
by hour = bin(_time, 1h), op_type = opType, outcome
| sort by hour desc
4.6 Cold-Load Latency Percentiles (Perf Regression Check)
['cv_telemetry']
| where type == 'cold_load_latency'
| where _time > ago(14d)
| summarize cold_loads = count(), avg_ms = avg(latencyMs),
p50_ms = percentile(latencyMs, 50), p95_ms = percentile(latencyMs, 95), p99_ms = percentile(latencyMs, 99)
by day = bin(_time, 1d)
| sort by day desc
4.7 Modal Open Without Close (Stuck Modal Detection)
['cv_telemetry']
| where type in ('modal_open', 'modal_close')
| where _time > ago(24h)
| summarize opens = countif(type == 'modal_open'), closes = countif(type == 'modal_close') by modalId
| extend unclosed = opens - closes
| where unclosed > 5
| sort by unclosed desc
4.8 API Error Rate by Endpoint
['cv_telemetry']
| where type == 'api_error'
| where _time > ago(24h)
| summarize error_count = count(), affected_workspaces = dcount(workspaceId) by endpoint, statusCode
| sort by error_count desc
| limit 30
4.9 P2P Byte Rate by Workspace (Anomaly Detection)
['cv_telemetry']
| where type == 'p2p_tier'
| where _time > ago(1h)
| summarize avg_inbound_bps = avg(byteRateInbound), avg_outbound_bps = avg(byteRateOutbound),
peak_inbound_bps = max(byteRateInbound)
by workspace_id = workspaceId
| sort by peak_inbound_bps desc
| limit 20
4.10 Agent Tool Call Success Rate
['cv_telemetry']
| where type == 'agent_tool_call'
| where _time > ago(7d)
| summarize total_calls = count(), avg_latency_ms = avg(latencyMs) by tool_name = toolName
| sort by total_calls desc
5. Privacy Contract
What each tier captures and does NOT capture
| Green | Yellow | Red | |
|---|---|---|---|
| Workspace ID | Never — only a daily SHA-256 hash that cannot be reversed | Yes — workspace_id attached to all events |
Yes |
| Peer ID | Never | Yes — peer_id attached to all events |
Yes |
| Op types | Never | Yes — op type string, not content | Yes |
| Error messages | Never | Yes — error type + message, no stack trace | Yes |
| Navigation | Never | Yes — route pattern | Yes |
| Modal IDs | Never | Yes | Yes |
| API endpoints | Never | Yes — pattern only (no query params or body) | Yes |
| Keystroke key names | Never | Never | Non-printable only (Enter, ArrowUp, etc.). Printable chars → 'char' |
| Mouse coordinates | Never | Never | Never — element type + test ID only |
| Clipboard contents | Never | Never | Never — action type only (read/write) |
| CV/CL document content | Never | Never | Never |
| Job titles / company names | Never | Never | Never |
| Personal information | Never | Never | Never |
| Workspace content of any kind | Never | Never | Never |
| Screenshot / DOM serialization | Never | Never | Only on explicit error capture (dom_snapshot), compressed, no plaintext |
Green is the legal target
At GA, all workspaces default to green. The green bundle physically cannot emit support or verbose events — the code that would do so is in separate Vite chunks that are never loaded for green workspaces. This is the primary defense layer (defense-in-depth #1): absent code cannot leak data.
Since telemetry is now browser-direct to Axiom (no server proxy), there is no server-side enforcement layer for yellow/red rejection. The defense is the bundle split: a green workspace never loads the yellow or red module, so those events never originate. The scoped ingest tokens prevent cross-dataset pollution (each token only writes to its own dataset).
Yellow and red require explicit opt-in. Red is further gated behind a UI interaction: 7
consecutive taps on the footer version string within 3 seconds opens a confirmation prompt.
On confirmation, settings.tier is updated to 'red' via the Yjs Y.Doc, which triggers the
red bundle to load on the next workspace navigation. The footer badge turns red while active.
Red tier = internal development only. It must never be the default for production workspaces.
The DEFAULT_WORKSPACE_TIER wrangler var controls the default on workspace creation.
6. Adding New Events
Recipe
Decide the tier. Green for aggregate-only signals that carry no workspace linkage. Yellow for anything that ties to a workspace ID and supports bug investigation. Red for session-replay-grade signals that are internal-dev-only.
Pick a unique event type name. Snake_case, descriptive, no abbreviations that would require a lookup. Check this catalog — the name must not conflict with an existing event.
Document the blob/double layout. Map your fields to
blobs[0..4]anddoubles[0..2]following the convention in §1. Assign the type name toblobs[0]and the workspace/token identifier toblobs[1]. Chooseindexes[0](the AE dimension key) — for most new events this should be the event type string.Add the emitter function to the appropriate tier module (
green.ts,yellow.ts, orred.ts). Yellow and red are supersets: add the function to yellow if yellow is the minimum tier, and also add it tored.ts(which replicates yellow's support event stream for self-containment).Add the server-side normalizer case in
/api/telemetry/event/+server.ts. Extend the field extraction logic in thefor (const ev of events)loop to map your new event's fields toprimaryandsecondarydouble slots correctly. If your event introduces a new blob beyondblobs[3], extend theblobsarray construction for that event type.Update this catalog. Add the new event to the appropriate tier section (§2.1, 2.2, or 2.3). Include: tier required, emitter function name, when fired, full slot table, and a bug pattern entry if the event is intended for regression detection.
Write a query. Add a representative SQL query to §4 that demonstrates how to use the new event for its intended purpose.
Naming constraints
- Event type names are permanent once in production — they appear in Analytics Engine data that persists for 30 days. Do not rename events; add a new one and mark the old one deprecated if a rename is needed.
- Blob string values are truncated at the server:
blobs[3]at 200 chars, endpoint patterns at 120 chars, op types at 80 chars, modal IDs at 80 chars. Design your values to be meaningful within these bounds. - Do not add more than 5 blob slots or 3 double slots without updating the AE data point shape comment in
+server.ts.
Tier selection checklist
Before choosing a tier, answer these questions:
| Question | If yes → |
|---|---|
Does the event carry workspace_id? |
Yellow minimum |
| Does the event capture user input (keystrokes, mouse, clipboard)? | Red only |
| Does the event require sub-second flush cadence? | Red only |
| Is this event intended for GA end users without explicit opt-in? | Green only — and strip all workspace/session identifiers |
| Does the event help answer "what did the user do before this bug"? | Yellow or red |
| Is the event for internal protocol debugging only? | Red |