Files
moeka-project/apps/server/src/otel/index.ts
T
RainbowBird e6fa39ed4b feat(server): voice pack and tts routing (#1905)
Track per-app TTS concurrency in Redis, route capped upstreams by
available pool capacity, and surface pool saturation metrics. Document
the Voice Pack plan so the remaining backend and card-binding work has
an explicit implementation map.
2026-06-05 22:39:30 +08:00

661 lines
25 KiB
TypeScript

import type { Counter, Histogram, ObservableGauge } from '@opentelemetry/api'
// NOTICE:
// HTTP server metrics (request duration, active requests) are emitted by
// `@hono/otel`'s `httpInstrumentationMiddleware` registered in `app.ts`. It
// records the standard semconv names with the matched Hono route pattern,
// so we don't create those handles here. We keep the auto HttpInstrumentation
// for OUTBOUND requests only (LLM gateway, Stripe, Resend) — see
// `instrumentation.ts`.
import type { Env } from '../libs/env'
import { useLogger } from '@guiiai/logg'
import { metrics, trace } from '@opentelemetry/api'
import { logs, SeverityNumber } from '@opentelemetry/api-logs'
import {
METRIC_AIRI_EMAIL_DURATION,
METRIC_AIRI_EMAIL_FAILURES,
METRIC_AIRI_EMAIL_SEND,
METRIC_AIRI_FLUX_CREDITED,
METRIC_AIRI_FLUX_UNBILLED,
METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_INVALID_HMAC,
METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_RELOAD,
METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_WRITE,
METRIC_AIRI_GEN_AI_GATEWAY_DECRYPT_FAILURES,
METRIC_AIRI_GEN_AI_GATEWAY_FALLBACK_COUNT,
METRIC_AIRI_GEN_AI_GATEWAY_KEY_EXHAUSTED_COUNT,
METRIC_AIRI_GEN_AI_GATEWAY_POOL_INFLIGHT,
METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED,
METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED,
METRIC_AIRI_GEN_AI_GATEWAY_SAME_STATUS_EXHAUSTION,
METRIC_AIRI_GEN_AI_GATEWAY_SUBSCRIBER_STATE,
METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS,
METRIC_AIRI_GEN_AI_STREAM_INTERRUPTED,
METRIC_AIRI_OBSERVABILITY_READ_ERRORS,
METRIC_AIRI_PRODUCT_EVENTS,
METRIC_AIRI_RATE_LIMIT_BLOCKED,
METRIC_AIRI_STRIPE_REVENUE,
METRIC_AIRI_TTS_CHARS,
METRIC_AIRI_TTS_PREFLIGHT_REJECTIONS,
METRIC_AUTH_ATTEMPTS,
METRIC_AUTH_FAILURES,
METRIC_CHARACTER_CREATED,
METRIC_CHARACTER_DELETED,
METRIC_CHARACTER_ENGAGEMENT,
METRIC_CHAT_MESSAGES,
METRIC_FLUX_CONSUMED,
METRIC_FLUX_INSUFFICIENT_BALANCE,
METRIC_GEN_AI_CLIENT_FIRST_TOKEN_DURATION,
METRIC_GEN_AI_CLIENT_OPERATION_COUNT,
METRIC_GEN_AI_CLIENT_OPERATION_DURATION,
METRIC_GEN_AI_CLIENT_TOKEN_USAGE_INPUT,
METRIC_GEN_AI_CLIENT_TOKEN_USAGE_OUTPUT,
METRIC_STRIPE_CHECKOUT_COMPLETED,
METRIC_STRIPE_CHECKOUT_CREATED,
METRIC_STRIPE_EVENTS,
METRIC_STRIPE_PAYMENT_FAILED,
METRIC_STRIPE_SUBSCRIPTION_EVENT,
METRIC_USER_ACTIVE_ROLLING,
METRIC_USER_ACTIVE_SESSIONS,
METRIC_USER_DISTINCT_ACTIVE,
METRIC_USER_LOGIN,
METRIC_USER_REGISTERED,
METRIC_USER_TOTAL,
METRIC_WS_CONNECTIONS_ACTIVE,
METRIC_WS_MESSAGES_RECEIVED,
METRIC_WS_MESSAGES_SENT,
} from '../utils/observability'
const logger = useLogger('otel')
export interface AuthMetrics {
attempts: Counter
failures: Counter
userRegistered: Counter
userLogin: Counter
/**
* Pull-based gauge for total registered users.
*
* Use when:
* - Reporting current account-base size. Pair with
* {@link AuthMetrics.userRegistered} for signup deltas over a time window.
*
* Expects:
* - Backed by `SELECT COUNT(*) FROM "user"`. Same cluster-wide truth as the
* other DB-backed gauges; dashboards MUST aggregate with `max()`/`avg()`,
* not `sum()`.
*/
totalUsers: ObservableGauge
/**
* Cluster-wide active session count, sourced from Postgres (Better Auth
* `session` table where `expires_at > NOW()`).
*
* Why ObservableGauge instead of UpDownCounter:
* - UpDownCounter drifts: TTL expiration never fires a -1, and multi-
* replica deploys split +1 / -1 across instances (signin on A, signout
* on B). The previous implementation went unboundedly positive.
* - Reading from the source-of-truth DB at scrape time makes the metric
* self-correcting.
*
* Multi-replica note:
* - Every replica reads the same DB and reports the same value, so the
* dashboard MUST aggregate with `max()` (or `avg()`), NOT `sum()`.
* Using sum() would multiply the real count by the replica count.
* - See `apps/server/docs/ai-context/observability-conventions.md`,
* "Multi-Replica Considerations".
*/
activeSessions: ObservableGauge
/**
* Pull-based gauge for distinct users with ≥1 non-expired session.
*
* Use when:
* - Querying real "active users" — not session rows. Better Auth creates a
* new `session` row per sign-in and per OIDC token refresh, and never
* GCs expired rows, so {@link AuthMetrics.activeSessions} drifts up
* over time even when the actual user base is small.
*
* Expects:
* - Backed by `SELECT COUNT(DISTINCT user_id) FROM session WHERE expires_at > now()`.
* Same cluster-wide truth as `activeSessions`; dashboards MUST aggregate
* with `avg()`, not `sum()` — see observability-conventions.md.
*/
distinctActiveUsers: ObservableGauge
/**
* Pull-based gauge for rolling-window distinct active users (DAU / WAU /
* MAU).
*
* Use when:
* - Reporting "how many users were active in the last 24h / 7d / 30d" —
* the standard product-engagement funnel, distinct from
* {@link AuthMetrics.distinctActiveUsers} which only counts users with a
* currently-live session.
*
* Expects:
* - Backed by `COUNT(*) FILTER (WHERE last_seen_at > now() - window)` over
* the `user` table. `last_seen_at` is touched on sign-in and on every
* OIDC access-token refresh (~hourly), so it is a per-user last-activity
* timestamp (see the `user.lastSeenAt` schema note).
* - Observed once per window with a `window` attribute (`24h` / `7d` /
* `30d`). Same cluster-wide truth as the other DB-backed gauges;
* dashboards MUST aggregate with `max()`/`avg()`, not `sum()`.
*/
rollingActiveUsers: ObservableGauge
}
export interface EngagementMetrics {
chatMessages: Counter
characterCreated: Counter
characterDeleted: Counter
characterEngagement: Counter
/**
* Pull-based gauge for active WebSocket connections.
*
* Use when:
* - Querying current concurrent WS connections in Grafana / alerts.
*
* Why ObservableGauge instead of UpDownCounter:
* - UpDownCounter is delta-based (+1 / -1) and drifts when disconnect
* handlers miss (process crash, SIGKILL, TCP RST, network blackhole).
* - ObservableGauge runs a callback at every export interval and reports
* the live registry size, so a missed -1 self-corrects on the next
* scrape instead of leaking forever.
*
* Expects:
* - Caller (`createChatWsHandlers`) registers exactly one callback via
* `addCallback`. Multiple callbacks would double-count.
*/
wsConnectionsActive: ObservableGauge
wsMessagesSent: Counter
wsMessagesReceived: Counter
}
export interface RevenueMetrics {
stripeCheckoutCreated: Counter
stripeCheckoutCompleted: Counter
stripePaymentFailed: Counter
stripeSubscriptionEvent: Counter
stripeEvents: Counter
stripeRevenue: Counter
fluxInsufficientBalance: Counter
fluxCredited: Counter
/**
* Flux value that the LLM proxy could not collect from the user. Fires from
* both the streaming and non-streaming completion paths.
*
* Use when:
* - Tracking real revenue leak in the LLM proxy.
*
* Labels (`reason`):
* - `debit_failed` — `consumeFluxForLLM` threw (DB error, or `balance <= 0`
* after a race lost). Counter records the *full* requested amount.
* - `partial_debit_drained` — user had `0 < balance < requested`, so we
* drained the balance to zero and charged what we could. Counter records
* `requested - charged` (the unbilled remainder only).
*
* Why this needs its own metric:
* - The streaming response is already sent (HTTP 200, tokens delivered) by
* the time we discover we can't collect in full. DB latency / HTTP 5xx
* alerts do NOT fire on this path — the failure is silent at the
* transport layer. This counter is the only signal that ties Flux value
* owed to a missed debit.
* - Recommended alert: `increase(airi_billing_flux_unbilled_total[5m]) > 0`
* pages on-call immediately on any sustained leak.
*/
fluxUnbilled: Counter
ttsChars: Counter
ttsPreflightRejections: Counter
}
export interface GenAiMetrics {
operationDuration: Histogram
operationCount: Counter
tokenUsageInput: Counter
tokenUsageOutput: Counter
fluxConsumed: Counter
firstTokenDuration: Histogram
streamInterrupted: Counter
}
export interface GatewayMetrics {
/**
* Per-attempt fallback event. Increments once per failing key try when the
* router moves on to the next key/upstream. Recommended labels:
* `provider`, `from_key`, `reason`.
*/
fallbackCount: Counter
/**
* Upstream error responses received during fallback iteration. Recommended
* labels: `provider`, `status_code`.
*/
upstreamErrors: Counter
/**
* All keys (across all upstreams) failed in a single request — the user gets
* a 5xx. Primary alert source for user-facing degradation.
* Recommended label: `provider`.
*
* Recommended alert:
* `increase(airi_gen_ai_gateway_key_exhausted_total[5m]) > 0` → page on-call.
*/
keyExhaustedCount: Counter
/**
* All keys in one request failed with the *same* upstream status code.
* Strong signal of account-level (shared-backend) rate limiting that
* per-key fallback cannot recover from — see plan D33 risk-acceptance
* and the adversarial finding ADV-PLAN-006.
* Recommended labels: `provider`, `status_code`.
*
* Recommended alert:
* `rate(airi_gen_ai_gateway_same_status_exhaustion_total[15m]) / rate(...request_count[15m]) > 0.05`
*/
sameStatusExhaustion: Counter
/**
* Local in-memory configKV cache reloaded (router config). Labels: `source`
* (`pubsub` | `ttl` | `manual`), `service_instance_id`.
*/
configReload: Counter
/**
* Envelope-crypto decryption auth-tag failures. Any >0 sample indicates
* config corruption or a master-key rotation misstep — investigate.
* Recommended labels: `provider`, `key_entry_id`.
*/
decryptFailures: Counter
/**
* Pub/Sub subscriber lifecycle transitions (`subscribed` |
* `reconnecting` | `error` | `closed`). Watch for sustained
* `reconnecting` — the TTL self-heal stops being ≤5s once the subscriber
* is dead.
*/
subscriberState: Counter
/**
* Admin endpoint write events for `LLM_ROUTER_CONFIG`. Labels: `result`
* (`success` | `4xx` | `5xx`), `actor_email`. Audit-trail surrogate
* given v1 keeps the flat-admin-role permission model (R16a known
* limitation).
*/
configWrite: Counter
/**
* Pub/Sub invalidation messages dropped because the HMAC did not verify.
* >0 = forged or replayed message — investigate Redis access boundary.
*/
configInvalidHmac: Counter
/**
* Capacity-aware TTS routing skipped a pool because its app_id was already at
* the concurrency cap (the pre-read said free but the atomic acquire lost the
* race, or every pool was full). Labels: `provider`, `app_id`.
*
* Recommended alert: sustained rate relative to TTS request volume means the
*pool is undersized — add app_ids or raise the cap.
*/
poolSlotRejected: Counter
/**
* Apool was circuit-broken after exhausting with a 429 (app_id concurrency
* exceeded upstream-side). Labels: `provider`, `app_id`. A pool with a high
* mark rate is being driven past its real upstream limit.
*/
poolSaturationMarked: Counter
/**
* Cluster-wide gauge of current in-flight requests per pool, sourced from
* Redis. Label: `app_id`. Every replica reports the same value — dashboards
* MUST aggregate with `avg()`, NOT `sum()` (see observability-conventions.md).
*/
poolInflight: ObservableGauge
}
export interface EmailMetrics {
send: Counter
failures: Counter
duration: Histogram
}
export interface RateLimitMetrics {
blocked: Counter
}
export interface ObservabilityMetrics {
/**
* Counts failures inside metric-pipeline callbacks (e.g. a DB-backed
* ObservableGauge that couldn't read from Postgres). Use for self-monitoring
* — when this is rising, treat the affected gauge's reported value as
* potentially stale.
*
* Labels: `metric` (the failing gauge's logical name).
*/
metricReadErrors: Counter
}
export interface ProductMetrics {
/**
* Low-cardinality product event counter.
*
* Use when:
* - Reporting feature/event volume in Prometheus and Grafana.
*
* Expects:
* - Labels stay bounded (`feature`, `action`, `status`, optional
* `source`). Never attach `user_id`, `session_id`, request ids, models
* with unbounded aliases, or free-form error messages here.
*/
events: Counter
}
export interface OtelInstance {
auth: AuthMetrics
engagement: EngagementMetrics
revenue: RevenueMetrics
genAi: GenAiMetrics
gateway: GatewayMetrics
email: EmailMetrics
rateLimit: RateLimitMetrics
observability: ObservabilityMetrics
product: ProductMetrics
}
/**
* Build the structured metric-handle bundle used across the app.
*
* Use when:
* - DI assembly in `apps/server/src/app.ts`. Returns `null` when OTel is
* disabled (no OTLP endpoint), so callers can skip wiring `metrics?.…`.
*
* Expects:
* - `instrumentation.ts` has already started NodeSDK (loaded via
* `tsx --import ./instrumentation.ts`). This function does NOT start the
* SDK — it only consumes the global MeterProvider that the preload set up.
* Calling it before the preload runs would yield NoopMeter for everything.
*
* Returns:
* - Metric bundle with primed counters (so low-traffic series show up in
* Prometheus from boot), or `null` when OTel is disabled.
*/
export function initOtel(env: Env): OtelInstance | null {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
logger.log('OpenTelemetry disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)')
return null
}
const meter = metrics.getMeter(env.OTEL_SERVICE_NAME)
// Auth & User metrics
const auth: AuthMetrics = {
attempts: meter.createCounter(METRIC_AUTH_ATTEMPTS, {
description: 'Number of authentication attempts',
}),
failures: meter.createCounter(METRIC_AUTH_FAILURES, {
description: 'Number of failed authentication attempts',
}),
userRegistered: meter.createCounter(METRIC_USER_REGISTERED, {
description: 'Number of new user registrations',
}),
userLogin: meter.createCounter(METRIC_USER_LOGIN, {
description: 'Number of user sign-ins',
}),
totalUsers: meter.createObservableGauge(METRIC_USER_TOTAL, {
description: 'Total registered users sourced from Postgres (cluster-wide; dashboard must use max(), not sum())',
}),
activeSessions: meter.createObservableGauge(METRIC_USER_ACTIVE_SESSIONS, {
description: 'Active user sessions sourced from Postgres (cluster-wide; dashboard must use avg(), not sum())',
}),
distinctActiveUsers: meter.createObservableGauge(METRIC_USER_DISTINCT_ACTIVE, {
description: 'Distinct users with ≥1 non-expired session — true active-user count, immune to per-row session inflation (cluster-wide; dashboard must use avg(), not sum())',
}),
rollingActiveUsers: meter.createObservableGauge(METRIC_USER_ACTIVE_ROLLING, {
description: 'Rolling-window distinct active users (DAU/WAU/MAU) from user.last_seen_at, labelled by window=24h|7d|30d (cluster-wide; dashboard must use max(), not sum())',
}),
}
// Engagement metrics
const engagement: EngagementMetrics = {
chatMessages: meter.createCounter(METRIC_CHAT_MESSAGES, {
description: 'Number of chat messages written or pulled',
}),
characterCreated: meter.createCounter(METRIC_CHARACTER_CREATED, {
description: 'Number of characters created',
}),
characterDeleted: meter.createCounter(METRIC_CHARACTER_DELETED, {
description: 'Number of characters deleted',
}),
characterEngagement: meter.createCounter(METRIC_CHARACTER_ENGAGEMENT, {
description: 'Number of character engagement actions (like/bookmark)',
}),
wsConnectionsActive: meter.createObservableGauge(METRIC_WS_CONNECTIONS_ACTIVE, {
description: 'Active WebSocket connections (live registry size, scraped per export interval)',
}),
wsMessagesSent: meter.createCounter(METRIC_WS_MESSAGES_SENT, {
description: 'Messages sent via WebSocket',
}),
wsMessagesReceived: meter.createCounter(METRIC_WS_MESSAGES_RECEIVED, {
description: 'Messages received via WebSocket',
}),
}
// Revenue metrics
const revenue: RevenueMetrics = {
stripeCheckoutCreated: meter.createCounter(METRIC_STRIPE_CHECKOUT_CREATED, {
description: 'Number of Stripe checkout sessions created',
}),
stripeCheckoutCompleted: meter.createCounter(METRIC_STRIPE_CHECKOUT_COMPLETED, {
description: 'Number of Stripe checkout sessions completed',
}),
stripePaymentFailed: meter.createCounter(METRIC_STRIPE_PAYMENT_FAILED, {
description: 'Number of failed Stripe payments',
}),
stripeSubscriptionEvent: meter.createCounter(METRIC_STRIPE_SUBSCRIPTION_EVENT, {
description: 'Number of Stripe subscription lifecycle events',
}),
stripeEvents: meter.createCounter(METRIC_STRIPE_EVENTS, {
description: 'Number of Stripe webhook events processed',
}),
stripeRevenue: meter.createCounter(METRIC_AIRI_STRIPE_REVENUE, {
description: 'Stripe revenue in smallest currency unit (e.g. cents)',
unit: 'minor_unit',
}),
fluxInsufficientBalance: meter.createCounter(METRIC_FLUX_INSUFFICIENT_BALANCE, {
description: 'Number of insufficient flux balance errors',
}),
fluxCredited: meter.createCounter(METRIC_AIRI_FLUX_CREDITED, {
description: 'Total flux credited to user balances, by source',
}),
fluxUnbilled: meter.createCounter(METRIC_AIRI_FLUX_UNBILLED, {
description: 'Flux owed but unbilled (post-stream debit failed). Real revenue leak.',
}),
ttsChars: meter.createCounter(METRIC_AIRI_TTS_CHARS, {
description: 'TTS input characters processed (billing base unit)',
}),
ttsPreflightRejections: meter.createCounter(METRIC_AIRI_TTS_PREFLIGHT_REJECTIONS, {
description: 'Pre-flight rejections from flux-meter assertCanAfford',
}),
}
// GenAI metrics (semconv: gen_ai.client.*)
const genAi: GenAiMetrics = {
operationDuration: meter.createHistogram(METRIC_GEN_AI_CLIENT_OPERATION_DURATION, {
description: 'GenAI client operation duration',
unit: 's',
}),
operationCount: meter.createCounter(METRIC_GEN_AI_CLIENT_OPERATION_COUNT, {
description: 'Number of GenAI client operations',
}),
tokenUsageInput: meter.createCounter(METRIC_GEN_AI_CLIENT_TOKEN_USAGE_INPUT, {
description: 'Total input (prompt) tokens consumed',
}),
tokenUsageOutput: meter.createCounter(METRIC_GEN_AI_CLIENT_TOKEN_USAGE_OUTPUT, {
description: 'Total output (completion) tokens consumed',
}),
fluxConsumed: meter.createCounter(METRIC_FLUX_CONSUMED, {
description: 'Total flux consumed',
}),
firstTokenDuration: meter.createHistogram(METRIC_GEN_AI_CLIENT_FIRST_TOKEN_DURATION, {
description: 'Time from request start to first streamed token (TTFB for streaming)',
unit: 's',
}),
streamInterrupted: meter.createCounter(METRIC_AIRI_GEN_AI_STREAM_INTERRUPTED, {
description: 'Streaming responses interrupted before completion',
}),
}
// Router gateway metrics (in-process LLM/TTS routing — KTD-3).
// Every counter alerts on a different failure shape; see metric-handle JSDoc
// on GatewayMetrics for the recommended PromQL.
const gateway: GatewayMetrics = {
fallbackCount: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_FALLBACK_COUNT, {
description: 'Per-attempt fallback events in the in-process LLM/TTS router',
}),
upstreamErrors: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS, {
description: 'Upstream error responses received during fallback iteration',
}),
keyExhaustedCount: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_KEY_EXHAUSTED_COUNT, {
description: 'All keys (across all upstreams) failed in a single request — primary user-facing alert',
}),
sameStatusExhaustion: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_SAME_STATUS_EXHAUSTION, {
description: 'All keys in one request failed with the same upstream status (account-level rate-limit signal)',
}),
configReload: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_RELOAD, {
description: 'Local in-memory router config cache reloaded (by source: pubsub / ttl / manual)',
}),
decryptFailures: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_DECRYPT_FAILURES, {
description: 'Envelope-crypto decryption auth-tag failures (config corruption or rotation misstep)',
}),
subscriberState: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_SUBSCRIBER_STATE, {
description: 'Pub/Sub subscriber lifecycle state transitions',
}),
configWrite: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_WRITE, {
description: 'Admin endpoint LLM_ROUTER_CONFIG write events (audit-trail surrogate)',
}),
configInvalidHmac: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_INVALID_HMAC, {
description: 'Pub/Sub invalidation messages dropped due to HMAC mismatch (forged or replayed)',
}),
poolSlotRejected: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SLOT_REJECTED, {
description: 'Capacity-aware TTS routing skipped a pool already at its app_id concurrency cap',
}),
poolSaturationMarked: meter.createCounter(METRIC_AIRI_GEN_AI_GATEWAY_POOL_SATURATION_MARKED, {
description: 'TTSpool circuit-broken after exhausting with a 429 (app_id concurrency exceeded)',
}),
poolInflight: meter.createObservableGauge(METRIC_AIRI_GEN_AI_GATEWAY_POOL_INFLIGHT, {
description: 'In-flight TTS requests per pool sourced from Redis (cluster-wide; dashboard must use avg(), not sum())',
}),
}
const email: EmailMetrics = {
send: meter.createCounter(METRIC_AIRI_EMAIL_SEND, {
description: 'Transactional emails accepted by Resend',
}),
failures: meter.createCounter(METRIC_AIRI_EMAIL_FAILURES, {
description: 'Transactional email send failures',
}),
duration: meter.createHistogram(METRIC_AIRI_EMAIL_DURATION, {
description: 'Email provider call duration',
unit: 's',
}),
}
const rateLimit: RateLimitMetrics = {
blocked: meter.createCounter(METRIC_AIRI_RATE_LIMIT_BLOCKED, {
description: 'Requests blocked by rate limiter',
}),
}
const observability: ObservabilityMetrics = {
metricReadErrors: meter.createCounter(METRIC_AIRI_OBSERVABILITY_READ_ERRORS, {
description: 'Failures reading metric values inside gauge callbacks',
}),
}
const product: ProductMetrics = {
events: meter.createCounter(METRIC_AIRI_PRODUCT_EVENTS, {
description: 'Low-cardinality product event volume. Distinct users live in Postgres product_events, not Prometheus labels.',
}),
}
// NOTICE:
// OTel SDK only emits a Counter time series after .add() runs the first time.
// Without this priming step, low-traffic counters (auth_failures_total,
// stripe_*_total, payment_failed, ...) never appear in Prometheus / Grafana
// until an event happens — making panels look broken on fresh deploys and
// making absence-based alerts impossible to author. add(0) registers the
// series with a baseline of 0 without distorting any rates.
// Removal condition: OTel SDK changes default to register Counters at create
// time (https://github.com/open-telemetry/opentelemetry-specification/issues/2298).
const counters = [
auth.attempts,
auth.failures,
auth.userRegistered,
auth.userLogin,
engagement.chatMessages,
engagement.characterCreated,
engagement.characterDeleted,
engagement.characterEngagement,
engagement.wsMessagesSent,
engagement.wsMessagesReceived,
revenue.stripeCheckoutCreated,
revenue.stripeCheckoutCompleted,
revenue.stripePaymentFailed,
revenue.stripeSubscriptionEvent,
revenue.stripeEvents,
revenue.stripeRevenue,
revenue.fluxInsufficientBalance,
revenue.fluxCredited,
revenue.fluxUnbilled,
revenue.ttsChars,
revenue.ttsPreflightRejections,
genAi.operationCount,
genAi.tokenUsageInput,
genAi.tokenUsageOutput,
genAi.fluxConsumed,
genAi.streamInterrupted,
gateway.fallbackCount,
gateway.upstreamErrors,
gateway.keyExhaustedCount,
gateway.sameStatusExhaustion,
gateway.configReload,
gateway.decryptFailures,
gateway.subscriberState,
gateway.configWrite,
gateway.configInvalidHmac,
email.send,
email.failures,
rateLimit.blocked,
observability.metricReadErrors,
product.events,
]
for (const counter of counters) counter.add(0)
return { auth, engagement, revenue, genAi, gateway, email, rateLimit, observability, product }
}
const severityMap: Record<string, SeverityNumber> = {
debug: SeverityNumber.DEBUG,
verbose: SeverityNumber.TRACE,
log: SeverityNumber.INFO,
info: SeverityNumber.INFO,
warn: SeverityNumber.WARN,
error: SeverityNumber.ERROR,
}
/**
* Emit a log record to OpenTelemetry.
* Automatically attaches the active span's traceId/spanId when available.
*/
export function emitOtelLog(
level: string,
context: string,
message: string,
attributes?: Record<string, string | number | boolean>,
): void {
const otelLogger = logs.getLogger(context)
const spanContext = trace.getActiveSpan()?.spanContext()
otelLogger.emit({
severityNumber: severityMap[level.toLowerCase()] ?? SeverityNumber.INFO,
severityText: level.toUpperCase(),
body: message,
attributes: {
...attributes,
...(spanContext && {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
}),
},
})
}