# Analytics Server-side recording of session volume, conversation content, tool usage, and failures for the realtime session relay. Backed by a small local SQLite database — no client-side instrumentation, since every session event already passes through our server (see [REALTIME_SESSION.md](REALTIME_SESSION.md)'s connection flow). This fills a seam that doc explicitly anticipated: _"we can add moderation, analytics, avatar-specific processing... in the middle later — without ever touching the browser client."_ ## The hook point `RealtimeSession` (`session.ts`) is the one place every session event in both directions already funnels through. Analytics attaches at choke points that already exist there for other reasons — `start()`, `close()`, `resolveToolCall()`, `sendToBrowser()` — rather than adding new plumbing. See `src/lib/server/core/analytics.ts` for the `AnalyticsRecorder` interface and its `better-sqlite3`-backed implementation. ## Why `better-sqlite3`, not `node:sqlite` Node has an experimental built-in SQLite module, but `better-sqlite3` was chosen instead: it's the mature, widely-used library with a stable synchronous API, which is what keeps every `AnalyticsRecorder` call in `session.ts` a plain fire-and-forget call rather than something that needs `await`/error-handling threaded through the session's existing synchronous methods. ## The transcript-buffering gotcha `transcript.done` (see `protocol.ts`) carries only `{ role }` — no text. Full message text has to be assembled server-side: - **Typed user text** (`input_text.send`): already a complete string — logged directly, no buffering. - **Assistant voice replies**: stream as many `transcript.delta{role:'assistant'}` chunks as OpenAI cares to send, followed by one text-less `transcript.done{role:'assistant'}`. `RealtimeSession` accumulates deltas into `assistantTranscriptBuffer` and flushes+logs the concatenated text on `done`. - **User voice transcripts**: today, OpenAI's `conversation.item.input_audio_transcription.completed` maps (in `providers/openai.ts`) to a single `transcript.delta{role:'user'}` event that already carries the _entire_ transcript — no corresponding `transcript.done{role:'user'}` is ever emitted by that mapping. So a user-role delta is logged immediately as a complete message, not buffered. **This rests on unverified live behavior** — the same caveat class as REALTIME_SESSION.md's "Still unverified" note on this exact OpenAI event. If a live session ever produces more than one `messages` row for a single spoken user turn, that assumption has broken and this logic needs revisiting (see "Verifying changes" below for how to check). ## A bundled correctness fix: upstream-disconnect now actually closes the session Before this change, `handleProviderEvent` only _forwarded_ a provider-originated `session.closed` event (e.g. the upstream WebSocket dropping) to the browser — it never called `this.close()`. That meant `RealtimeSession`'s own state never updated and no session end time was ever recorded for that path. Since "session end time" is a stated goal of this feature, `handleProviderEvent` now also calls `this.close('upstream_disconnected')` when it sees a `session.closed` event (forwarding first, since `close()` moves the browser socket out of `OPEN` and `sendToBrowser` no-ops after that). ## Schema Four tables, `CREATE TABLE IF NOT EXISTS` on startup — no migration framework; a future column addition is a manual `ALTER TABLE` added to `SCHEMA_SQL` in `analytics.ts`: - `sessions(id, model, voice, started_at, ended_at, end_reason)` - `messages(id, session_id, role, text, created_at)` - `tool_calls(id, session_id, call_id, name, arguments, output, duration_ms, created_at)` - `events(id, session_id, type, detail, created_at)` — `session_id` is nullable here (only here): a validation error can occur before `session.start` completes, when no `sessions` row exists yet. Foreign keys are declared for documentation but explicitly not enforced (`PRAGMA foreign_keys = OFF` in the constructor — `better-sqlite3` defaults this pragma to _on_, so it has to be turned off explicitly, not just left unset) — this is a logging sink, not a system of record, and a stray write should never be silently dropped by a constraint violation. ## Configuration Env vars are documented in full in [docs/CONFIGURATION.md](CONFIGURATION.md) — `ANALYTICS_DB_PATH` (parent directory created automatically if missing; `/data` is gitignored), plus `ANALYTICS_ENABLED` and three per-category toggles, described here since the selection logic is analytics-specific: `ANALYTICS_ENABLED=false` selects `NoopAnalyticsRecorder` instead of `SqliteAnalyticsRecorder` at the composition root (`server.js` / `dev-plugin.ts`) — every method is a no-op, so no `data/analytics.db` file is created at all, not just left empty. With analytics enabled, three independent toggles gate what `SqliteAnalyticsRecorder` actually writes: `ANALYTICS_LOG_MESSAGES` (the `messages` table), `ANALYTICS_LOG_TOOL_CALLS` (the `tool_calls` table), `ANALYTICS_LOG_EVENTS` (the `events` table). Each is a guard at the top of the corresponding recorder method — `session.ts`'s call sites are unaffected and still call every method unconditionally at the same lifecycle hook points described above. There is no toggle for the `sessions` row (start/end, model, voice, timing): that base volume signal is always recorded whenever the recorder is real, regardless of the three category toggles. ## PII and retention `analytics.db` stores full raw conversation text (both roles) and tool call arguments/outputs verbatim, in a local, unencrypted SQLite file, with no retention window, no rotation, no redaction, and no per-user consent tracking. That's fine for internal development or a demo of this base template — it is **not** production-safe as shipped. A client project deploying this for real users needs to add a real retention/redaction policy before treating this as production telemetry; don't assume GDPR/CCPA compliance out of the box. ## What's deliberately not built yet - A query/reporting UI — this is a write-only sink today, read it with the `sqlite3` CLI or any SQLite client. - Migration tooling beyond manual `ALTER TABLE` additions to `SCHEMA_SQL`. - DB export/backup, log rotation for the file itself. - A graceful-shutdown/flush hook — no composition root in this codebase has one for anything yet, including the existing provider connections. - Multi-process-safe writes beyond WAL's built-in single-writer guarantee (matches the existing "in-memory, single-process only" constraint on session storage itself). ## Verifying changes - `npm run check` / `npm run lint` — type/lint correctness. Note `check` uses `bundler` module resolution and will **not** catch a missing `.js` extension on a relative import inside `src/lib/server/core/**`. - `npm run build` — also runs the standalone `tsc -p tsconfig.server.json` compile (`nodenext` resolution) that `server.js` depends on; this is the step that catches a missing `.js` extension or a file placed outside that config's `include` globs. - `npm run test:unit` — `analytics.spec.ts` (schema + each recorder method against `:memory:`, plus a WAL-mode check against a real temp file) and the extended `session.spec.ts` (asserts `AnalyticsRecorder` calls happen at the right lifecycle points, using a hand-written `FakeAnalyticsRecorder` in the same no-`vi.fn()` style as this file's other fakes). - **Live smoke test**: set `.env`, `npm run dev`, open `/demo/realtime`, and hold a real conversation — a typed message, a full voice turn in both directions, and a tool call (the demo project registers `get_current_time` (server) and `flash_page` (client) out of the box). Then inspect `data/analytics.db` directly: ```bash sqlite3 data/analytics.db "select * from sessions;" sqlite3 data/analytics.db "select id, role, substr(text,1,80), created_at from messages order by id;" sqlite3 data/analytics.db "select call_id, name, duration_ms from tool_calls;" sqlite3 data/analytics.db "select type, detail from events;" ``` Confirm exactly one assistant `messages` row per spoken AI turn with the **full** assembled text (not fragments), exactly one user `messages` row per spoken user turn (the direct check on the unverified single-chunk assumption above), and — kill the dev server or close the tab mid-session once — that `sessions.ended_at`/`end_reason` get populated (the direct check on the bug fix above). Remember `src/lib/server/core/**` isn't part of Vite's HMR — restart `npm run dev` after editing anything here.