Files
moeka-project/apps/server/src/scripts/otel/smoke.ts
T
RainbowBird 4da4a72703 feat(server): finalize in-process LLM/TTS router cutover
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.
2026-05-18 23:32:33 +08:00

70 lines
2.8 KiB
TypeScript

/**
* OTel metric-registration smoke test.
*
* Verifies every Counter created by `initOtel` is primed (.add(0)) so the
* Prometheus exporter sees the time series at boot, not just after the first
* real event. Without priming, low-traffic counters
* (auth_failures_total, stripe_*_total, payment_failed, ...) never appear in
* Grafana until an event happens — making panels look broken on fresh deploys
* and absence-based alerts impossible to author.
*
* Histograms (gen_ai.client.first_token.duration, airi.email.duration, ...)
* are intentionally NOT in the output — they only register on first .record().
*
* NOTE: Run WITHOUT `--import ./instrumentation.ts`. The preload would start
* a real NodeSDK with OTLP exporter and override the InMemoryMetricExporter
* this smoke installs as the global MeterProvider.
*
* Usage:
* pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts
*/
import { Buffer } from 'node:buffer'
import { env, exit } from 'node:process'
import { metrics } from '@opentelemetry/api'
import { AggregationTemporality, InMemoryMetricExporter, MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE)
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 100 })
const provider = new MeterProvider({ readers: [reader] })
metrics.setGlobalMeterProvider(provider)
// Smoke-test env: stand in for required vars so parseEnv passes; OTLP endpoint
// is set to a localhost URL that won't actually receive anything because we
// flush via the in-memory reader.
env.DATABASE_URL ??= 'postgres://test'
env.REDIS_URL ??= 'redis://test'
env.BETTER_AUTH_SECRET ??= 'test'
env.AUTH_GOOGLE_CLIENT_ID ??= 'test'
env.AUTH_GOOGLE_CLIENT_SECRET ??= 'test'
env.AUTH_GITHUB_CLIENT_ID ??= 'test'
env.AUTH_GITHUB_CLIENT_SECRET ??= 'test'
// 32 deterministic bytes is enough to satisfy env validation; the smoke
// script never actually hits the router.
env.LLM_ROUTER_MASTER_KEY ??= Buffer.alloc(32, 0xAA).toString('base64')
env.OTEL_EXPORTER_OTLP_ENDPOINT ??= 'http://localhost:4318'
const { initOtel } = await import('../../otel/index')
const { parseEnv } = await import('../../libs/env')
const parsed = parseEnv(env)
const inst = initOtel(parsed)
if (!inst) {
console.error('initOtel returned null (OTEL_EXPORTER_OTLP_ENDPOINT not set?)')
exit(1)
}
await new Promise(r => setTimeout(r, 300))
await reader.forceFlush()
const exported = exporter.getMetrics()
const names = []
for (const rm of exported) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) names.push(m.descriptor.name)
}
}
console.info('REGISTERED:')
for (const n of [...new Set(names)].sort()) console.info(` ${n}`)
await provider.shutdown()
exit(0)