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.
70 lines
2.8 KiB
TypeScript
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)
|