You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

9.7 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 (packages/core/src/server/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 packages/core/src/server/analytics.ts (exported from @webavatar/webavatar-core/server) 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 packages/core/src/server/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 packages/core/src/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 packages/core/src/server/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 — ANALYTICS_DB_PATH (parent directory created automatically if missing; apps/*/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 inside the shared createRealtimeRuntime factory (packages/core/src/server/index.ts), which both composition roots — apps/template/server.js and the ./vite dev plugin — call into rather than each duplicating the choice — every method is a no-op, so no apps/template/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 packages/core/src/server/**.
  • npm run build — also runs the standalone tsc -p tsconfig.server.json compile (nodenext resolution) that apps/template/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 apps/template/data/analytics.db directly:
    sqlite3 apps/template/data/analytics.db "select * from sessions;"
    sqlite3 apps/template/data/analytics.db "select id, role, substr(text,1,80), created_at from messages order by id;"
    sqlite3 apps/template/data/analytics.db "select call_id, name, duration_ms from tool_calls;"
    sqlite3 apps/template/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). See REALTIME_SESSION.md's "Dev-mode reload behavior" table for what actually hot-reloads vs. needs a restart when editing package source — and note you need the root npm run dev, not apps/template's own, for edits under packages/core/src/server/** to take effect at all.