## What
Closes the three structural gaps in AIRI's product analytics: the signup
surface had zero instrumentation, the payment funnel had no terminator
in PostHog, and SPA route changes emitted no pageviews. Also adds
semantic events for character cards, desktop-only features, and
data-maintenance actions.
## User paths
- User signs up / logs in / verifies email / resets password / links
OAuth / deletes account → each step now emits a PostHog event from
`apps/ui-server-auth` (previously fully uninstrumented), with
`identify()` wired on session load so anonymous funnel events merge into
the user person.
- User pays via Stripe → webhook writes `product_events` as before, and
the product-events service now forwards `payment_completed` (plus signup
and subscription lifecycle facts) to PostHog via posthog-node
`captureImmediate`, keyed by the Better Auth user id → the
`checkout_started → payment_completed` funnel closes end-to-end.
Per-request LLM/TTS volume is explicitly not forwarded.
- User navigates between routes in any surface (web / desktop / pocket /
docs) → `$pageview` + `$pageleave` fire per route change via the
posthog-js `defaults: '2025-05-24'` preset in the shared
`posthog.config.ts`.
- User creates / imports / duplicates / edits a ccv3 card, switches
stage background, runs destructive data actions (export / import / clear
chats, reset providers, wipe app data), or uses desktop differentiators
(Spotlight send, widget windows, in-app updater, MCP server management,
pairing QR) → dedicated low-cardinality events.
## Notable decisions
- Server forwarding defaults on: `POSTHOG_PROJECT_KEY` defaults to the
shared browser-safe phc_* project key; set it to an empty string to
disable. Postgres `product_events` remains the source of truth.
- Cross-surface events (`oauth_callback_failed`, account lifecycle)
share one stage vocabulary exported from stage-ui so the two emitters
cannot drift silently.
- Events captured right before full-page navigation use `sendBeacon` so
they survive the redirect (checkout, OAuth consent handoff, login
redirect).
- Removed dead wrappers (`trackSignup`, `trackFirstModelSelected`,
`trackModelChanged`) that duplicated live event streams under second
names.
## How tested
- `pnpm -F @proj-airi/server exec vitest run
src/services/domain/product-events.test.ts` — 6 passed, covering the
forwarding allowlist, the `user_signed_up → signup_completed` mapping,
non-forwarded per-request actions, and a throwing sink not failing the
webhook path nor losing the DB row.
- stage-ui suites (`use-analytics`, `use-linked-accounts`, exports
contract) — 22 passed, including new account/card/data/desktop event
assertions.
- Real transport smoke: posthog-node `captureImmediate` against
`us.i.posthog.com` with the production key resolved in 1380ms (one
`server_forwarding_smoke_test` event left in the project; filter by
event name).
- Browser-tested pageviews: `VITE_ENABLE_POSTHOG=true` dev build, two
`history.pushState` route changes each produced a `$pageview` with
`$pathname`, `navigation_type: pushState`, previous-page dwell time, and
the `surface` super property; batched POST to `us.i.posthog.com/e/`
returned 200. Note: posthog-js drops events from automated browsers
(`navigator.webdriver`) by default — the verification session bypassed
the bot filter locally; production config is untouched.
- Typecheck and lint pass for server, stage-ui, stage-pages, stage-web,
stage-tamagotchi, ui-server-auth.
Full verification record:
`apps/server/docs/ai-context/verifications/posthog-forwarding-and-pageview.md`
## Follow-ups (not in this PR)
- Bot channel usage stats (Discord / Telegram) once they route through
server-runtime counters.
- Main-process desktop events (tray menu, global shortcut fire) need
renderer relay plumbing.
- Confirm `payment_completed` arrives in PostHog after the first real
Stripe payment post-deploy.
https://claude.ai/code/session_01Q1yGavkQ1P41YhTWE4XKex
Why
- src/services/ was an unordered mix of single-file services and module
directories with no shared classification axis, plus several long-dead
admin batch helpers that survived the move to the simpler synchronous
admin-flux-grants flow.
What
- services/ now has two top-level layers:
domain/ — DB state + business rules (billing, characters, chats,
flux, flux-transaction, llm-router, providers, request-log,
stripe, user-deletion, admin/{flux-grants,router-config})
adapters/ — thin wrappers over external SDKs / infra (config-kv, email,
posthog, tts/)
- admin/* moved under domain/admin/ with consistent plural names
(flux-grants, router-config).
- tts-adapters/ collapsed to adapters/tts/ (no redundant -adapters suffix
once nested under adapters/).
- 63 src files + scripts/e2e-llm-router.ts + tests/verifications/_harness.ts
had relative imports rewritten; git mv preserves blame.
- apps/server/CLAUDE.md and docs/ai-context/*.md updated to match new paths.
Dead code removed
- services/admin-flux-grant-batches/ (service + worker + tests, 1090 LOC) —
superseded by admin-flux-grants and never wired into app.ts.
- routes/admin/flux-grant-batches/ — same.
- utils/redis-compressed.ts + test — zero production call sites.
- llm-router/index.ts re-exports trimmed from 26 to 6; only symbols with
external consumers are kept.
Intentionally kept
- schemas/flux-grant-batch.ts and its schemas/index.ts export remain so the
drizzle-kit generate diff stays empty. Removing them is a separate PR
that owns the drop-table migration for flux_grant_batch /
flux_grant_batch_recipient.
Verification
- pnpm -F @proj-airi/server typecheck: passes.
- pnpm exec eslint apps/server: 49 errors, identical to main baseline
(all are pre-existing node/prefer-global/buffer in envelope-crypto and
scripts/e2e-llm-router; untouched by this change).
- Vitest passes per-file; the 6 mockDB hook timeouts under full-parallel
run are the known pushSchema-per-worker infra cost, not a regression.
The two seed scripts are fully superseded by the new admin endpoint
`POST /api/admin/config/router` — same encryption, same configKV
writes, same `configkv:invalidate` publish, plus auth + audit + body
limits. Keeping both code paths created a drift risk on the AAD label
and the merge semantics.
Doc + test fallout:
- `e2e-llm-router.ts` now points readers to the admin endpoint for
the prerequisite seed step.
- `docs/ai-context/verifications/llm-router.md` and
`streaming-tts.md` get curl-based seed instructions; the 2026-05-15
llm-router evidence stays intact with a note that the script it
used has since been removed.
- The U9 follow-up entry in `llm-router.md` flips from "not shipped"
to "partially shipped" — ETag + HMAC publish are still deferred,
so the `config_write` / `config_invalid_hmac` Grafana panels stay
parked.
- Self-edit on the admin route + `app.ts` docstrings to drop the
earlier "scripts stay as break-glass" wording.
Why:
- Add a real bidirectional streaming TTS path: raw LLM tokens are
forwarded to the upstream model (Volcengine v3 via the unspeech ws
bridge) without client-side segmentation, so the model owns sentence
splitting and audio chunks play as they arrive.
- Move audio endpoints out of /api/v1/openai/. `/audio/voices`,
`/audio/models`, `/audio/voices/streaming` are not real OpenAI public
APIs, and the streaming TTS surface has nothing to do with OpenAI —
keeping them under /openai/ mislabelled the contract.
- Introduce `capabilities.speech.transport` on ProviderDefinition so
future streaming providers (ElevenLabs / Cartesia / OpenAI Realtime)
opt in without touching Stage.vue or the session factory.
- Unify Stage.vue's TTS path through a single StageTtsSession so the
chat-orchestrator hooks no longer branch on provider id.
What:
- apps/server: new ws proxy /api/v1/audio/speech/ws bridges client ↔
unspeech with auth, pre-flight flux check, billing from upstream
session.finished.usage, OTel spans.
- apps/server: audio routes moved from /api/v1/openai/audio/* to
/api/v1/audio/* (hard cutover; 404 sentinel tests added).
- apps/server: new /api/v1/audio/voices/streaming proxy reads voices
from unspeech /api/voices?provider=volcengine.
- apps/server: new STREAMING_TTS_UPSTREAM configKV entry +
scripts/seed-streaming-tts.ts.
- stage-ui: new libs/speech/streaming-pipeline.ts opens one ws per LLM
intent (appendText / finish / cancel + onSentence / onError / onDone).
- stage-ui: new libs/speech/tts-session.ts — StageTtsSession interface
with segmenter and streaming adapters; factory dispatches by
capabilities.speech.transport instead of hard-coded provider id.
- stage-ui: providerOfficialSpeechStreaming with capabilities.speech =
{ transport: 'bidirectional-ws' }; settings page with model/voice
picker + ws-based preview.
- stage-ui: Stage.vue chat hooks collapsed to a single currentSession;
hot-swap watcher cancels mid-session on provider/voice/model change;
unmount cancels and drains playback.
Tests:
- 9 streaming-pipeline tests (happy path / buffered / error / cancel /
truncation)
- 11 tts-session tests (factory branch coverage + adapter contracts)
- 4 audio-speech-ws route tests (forwarding / billing / pre-flight /
config-missing)
- 3 legacy-path 404 sentinels in v1 route tests
- Verification doc updated to reflect automated coverage.
End-state of the multi-step KTD-5 / KTD-6 / U8 work. The knoway sidecar
is no longer reachable from server code; the router is required at boot
and now owns chat completions, TTS synthesis, and voice catalog listing.
Highlights:
- LLM_ROUTER_MASTER_KEY becomes required; app.ts drops the graceful-
skip branch and the chat fallback fetch path is gone.
- /audio/speech and /audio/voices route through new routeTts /
listTtsVoices entries that reuse the chat key-rotator + per-attempt
timeout + abort propagation.
- DEFAULT_CHAT_MODEL / DEFAULT_TTS_MODEL move from env to configKV so
default-model swaps are hot-reloadable via Pub/Sub.
- GATEWAY_BASE_URL removed from env schema, .env, .env.local, smoke,
verification harness. Redis upstream-voices cache deleted — catalogs
come from in-process adapter JSON.
- routeTts splits adapter error contract by ApiError statusCode:
4xx propagates without fallback; 5xx folds into the network-failure
fallback path. handleTTS wraps billing + span attribute in try/finally
to plug a span leak when ttsMeter.accumulate() throws.
- seed-router-config.ts rewritten with --merge (default) / --reset /
--dry-run modes and env-var key handoff (OPENROUTER_KEY / AZURE_KEY /
DASHSCOPE_KEY) so prod seed flows never put plaintext on the CLI.
Adds DashScope CosyVoice seeding.
Docs (CLAUDE.md, architecture-overview.md, transport-and-routes.md)
reflect the new boundary. verifications/llm-router.md replaces the
overstated "U1-U9 shipped" line with an evidence-vs-pending table.
Tests: full 40-file / 343-case server suite green. New regressions pin
ApiError 4xx → no-fallback, ApiError 5xx → fallback, TTS billing
failure → span closed and error propagated.
- Added a new ObservableGauge for distinct active users to track real active user count, mitigating session row inflation issues.
- Updated the Grafana dashboard to reflect changes, including the removal of redundant WS Connections panel and the addition of new metrics for active sessions and distinct users.
- Improved documentation for verification automation processes, outlining a structured approach to automate verification steps and maintain evidence of tests.
The Redis Stream `billing-events` + `worker` Railway role +
advisory-lock poller layered together didn't actually buy us reliability
— `debitFlux` swallowed XADD failures, leaving the door open to "balance
updated, ledger row never written". Collapse the whole thing back to:
`creditFlux` and `debitFlux` write `flux_transaction` ledger rows inline
within the same DB transaction that mutates `user_flux`, and `(user_id,
request_id)` remains the partial unique index that keeps retries safe.
Concrete changes:
- Inline ledger inserts in `BillingService.{debitFlux, creditFlux,
creditFluxFromStripeCheckout, creditFluxFromInvoice}`; drop `billingMq`
and `publishEvent` plumbing entirely.
- `routes/openai/v1` writes `llm_request_log` synchronously via the
existing `requestLogService`; the duplicate `llm-request-log.ts` service
module is removed.
- `bin/run-worker.ts`, `libs/mq/*`,
`services/billing/billing-events.ts`,
`services/billing/billing-consumer-handler.ts`, and matching tests are
deleted. CLI now exposes only `api`.
- `BILLING_EVENTS_*` env vars and the `DEFAULT_BILLING_EVENTS_STREAM`
helper are dropped; `docker-compose.yml` no longer ships a worker
service.
- `docs/ai-context/{workers-and-runtime, billing-architecture,
redis-boundaries-and-pubsub, data-model-and-state,
architecture-overview, README}.md`, `CLAUDE.md`, and the existing
verification docs are updated to describe the single-process synchronous
pipeline.
Tests: 29 files / 247 cases pass. Production deployments need to drop
the worker Railway service after this lands.