9.1 KiB
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'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 keeps analytics writes compatible with the session's existing synchronous lifecycle methods.
Failure isolation
Analytics is best-effort telemetry, not part of session correctness. Every AnalyticsRecorder call in session.ts runs through one non-throwing boundary: a full disk, locked database, or other recorder failure is reported through the server logger under the analytics category, then normal provider traffic, browser delivery, and teardown continue. Failure reporting is itself guarded so a broken logger cannot turn a telemetry failure back into a session failure.
This boundary covers writes after the recorder has been constructed. Recorder construction still happens eagerly at process startup; an invalid/unwritable database path is a configuration or boot failure rather than a mid-session degradation case.
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-lesstranscript.done{role:'assistant'}.RealtimeSessionaccumulates deltas intoassistantTranscriptBufferand flushes+logs the concatenated text ondone. - User voice transcripts: today, OpenAI's
conversation.item.input_audio_transcription.completedmaps (inproviders/openai.ts) to a singletranscript.delta{role:'user'}event that already carries the entire transcript — no correspondingtranscript.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_idis nullable here (only here): a validation error can occur beforesession.startcompletes, when nosessionsrow 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 — 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
sqlite3CLI or any SQLite client. - Migration tooling beyond manual
ALTER TABLEadditions toSCHEMA_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. Notecheckusesbundlermodule resolution and will not catch a missing.jsextension on a relative import insidesrc/lib/server/core/**.npm run build— also runs the standalonetsc -p tsconfig.server.jsoncompile (nodenextresolution) thatserver.jsdepends on; this is the step that catches a missing.jsextension or a file placed outside that config'sincludeglobs.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 extendedsession.spec.ts(assertsAnalyticsRecordercalls happen at the right lifecycle points, using a hand-writtenFakeAnalyticsRecorderin 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 registersget_current_time(server) andflash_page(client) out of the box). Then inspectdata/analytics.dbdirectly: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
messagesrow per spoken AI turn with the full assembled text (not fragments), exactly one usermessagesrow 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 — thatsessions.ended_at/end_reasonget populated (the direct check on the bug fix above). Remembersrc/lib/server/core/**isn't part of Vite's HMR — restartnpm run devafter editing anything here.