diff --git a/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json b/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json index f0226c83a..fd6df97b1 100644 --- a/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json +++ b/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json @@ -48,7 +48,7 @@ "type": "stat", "targets": [ { - "expr": "sum(rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "expr": "sum(rate(http_server_request_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", "legendFormat": "req/s" } ] @@ -80,7 +80,7 @@ "type": "stat", "targets": [ { - "expr": "sum(rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\", deployment_environment=~\"$env\", http_status_code=~\"5..\"}[$__rate_interval])) / sum(rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "expr": "sum(rate(http_server_request_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\", http_status_code=~\"5..\"}[$__rate_interval])) / sum(rate(http_server_request_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", "legendFormat": "error rate" } ] @@ -94,11 +94,11 @@ "mode": "absolute", "steps": [ { "color": "green", "value": null }, - { "color": "yellow", "value": 200 }, - { "color": "red", "value": 1000 } + { "color": "yellow", "value": 0.2 }, + { "color": "red", "value": 1.0 } ] }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 8, "x": 16, "y": 1 }, @@ -108,11 +108,11 @@ "colorMode": "value", "graphMode": "area" }, - "title": "P95 Latency", + "title": "P95 Latency (excl. LLM)", "type": "stat", "targets": [ { - "expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_milliseconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\", http_route!~\"/api/v1/openai/.*\"}[$__rate_interval])) by (le))", "legendFormat": "p95" } ] @@ -136,7 +136,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (http_route) (rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "expr": "sum by (http_route) (rate(http_server_request_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", "legendFormat": "{{http_route}}" } ] @@ -150,7 +150,7 @@ "lineInterpolation": "smooth", "fillOpacity": 20 }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 }, @@ -159,7 +159,7 @@ "type": "timeseries", "targets": [ { - "expr": "histogram_quantile(0.95, sum by (le, http_route) (rate(http_server_request_duration_milliseconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by (le, http_route) (rate(http_server_request_duration_seconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval])))", "legendFormat": "{{http_route}}" } ] @@ -182,7 +182,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (http_status_code) (rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "expr": "sum by (http_status_code) (rate(http_server_request_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", "legendFormat": "{{http_status_code}}" } ] @@ -318,7 +318,7 @@ "lineInterpolation": "smooth", "fillOpacity": 20 }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 43 }, @@ -327,7 +327,7 @@ "type": "timeseries", "targets": [ { - "expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(db_client_operation_duration_milliseconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(db_client_operation_duration_seconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval])))", "legendFormat": "{{db_operation}}" } ] @@ -350,7 +350,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (db_operation) (rate(db_client_operation_duration_milliseconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "expr": "sum by (db_operation) (rate(db_client_operation_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", "legendFormat": "{{db_operation}}" } ] @@ -402,7 +402,7 @@ "lineInterpolation": "smooth", "fillOpacity": 20 }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 60 }, @@ -411,7 +411,7 @@ "type": "timeseries", "targets": [ { - "expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(db_client_operation_duration_milliseconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\", db_system=\"redis\"}[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(db_client_operation_duration_seconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\", db_system=\"redis\"}[$__rate_interval])))", "legendFormat": "{{db_operation}}" } ] @@ -434,7 +434,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (db_operation) (rate(db_client_operation_duration_milliseconds_count{service_name=~\"$service\", deployment_environment=~\"$env\", db_system=\"redis\"}[$__rate_interval]))", + "expr": "sum by (db_operation) (rate(db_client_operation_duration_seconds_count{service_name=~\"$service\", deployment_environment=~\"$env\", db_system=\"redis\"}[$__rate_interval]))", "legendFormat": "{{db_operation}}" } ] @@ -473,7 +473,7 @@ "type": "stat", "targets": [ { - "expr": "sum(rate(llm_request_count_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "expr": "sum(rate(gen_ai_client_operation_count_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", "legendFormat": "req/s" } ] @@ -487,11 +487,11 @@ "mode": "absolute", "steps": [ { "color": "green", "value": null }, - { "color": "yellow", "value": 500 }, - { "color": "red", "value": 2000 } + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 2.0 } ] }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 8, "x": 8, "y": 69 }, @@ -505,7 +505,7 @@ "type": "stat", "targets": [ { - "expr": "histogram_quantile(0.95, sum(rate(llm_request_duration_milliseconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.95, sum(rate(gen_ai_client_operation_duration_seconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval])) by (le))", "legendFormat": "p95" } ] @@ -537,7 +537,7 @@ "type": "stat", "targets": [ { - "expr": "sum(rate(flux_consumed_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "expr": "sum(rate(airi_billing_flux_consumed_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", "legendFormat": "flux/s" } ] @@ -561,7 +561,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (gen_ai_request_model) (rate(llm_request_count_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval]))", + "expr": "sum by (gen_ai_request_model) (rate(gen_ai_client_operation_count_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval]))", "legendFormat": "{{gen_ai_request_model}}" } ] @@ -584,11 +584,11 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (gen_ai_operation_name) (rate(llm_request_count_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_operation_name!=\"\"}[$__rate_interval]))", + "expr": "sum by (gen_ai_operation_name) (rate(gen_ai_client_operation_count_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_operation_name!=\"\"}[$__rate_interval]))", "legendFormat": "{{gen_ai_operation_name}}" }, { - "expr": "sum by (airi_gen_ai_operation_kind) (rate(llm_request_count_total{service_name=~\"$service\", deployment_environment=~\"$env\", airi_gen_ai_operation_kind!=\"\"}[$__rate_interval]))", + "expr": "sum by (airi_gen_ai_operation_kind) (rate(gen_ai_client_operation_count_total{service_name=~\"$service\", deployment_environment=~\"$env\", airi_gen_ai_operation_kind!=\"\"}[$__rate_interval]))", "legendFormat": "{{airi_gen_ai_operation_kind}}" } ] @@ -602,7 +602,7 @@ "lineInterpolation": "smooth", "fillOpacity": 20 }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 85 }, @@ -611,7 +611,7 @@ "type": "timeseries", "targets": [ { - "expr": "histogram_quantile(0.95, sum by (le, gen_ai_request_model) (rate(llm_request_duration_milliseconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by (le, gen_ai_request_model) (rate(gen_ai_client_operation_duration_seconds_bucket{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval])))", "legendFormat": "{{gen_ai_request_model}}" } ] @@ -634,7 +634,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (gen_ai_request_model) (rate(flux_consumed_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval]))", + "expr": "sum by (gen_ai_request_model) (rate(airi_billing_flux_consumed_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval]))", "legendFormat": "{{gen_ai_request_model}}" } ] @@ -657,7 +657,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (gen_ai_request_model) (rate(llm_tokens_prompt_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval]))", + "expr": "sum by (gen_ai_request_model) (rate(gen_ai_client_token_usage_input_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval]))", "legendFormat": "{{gen_ai_request_model}}" } ] @@ -680,7 +680,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (gen_ai_request_model) (rate(llm_tokens_completion_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval]))", + "expr": "sum by (gen_ai_request_model) (rate(gen_ai_client_token_usage_output_total{service_name=~\"$service\", deployment_environment=~\"$env\", gen_ai_request_model!=\"\"}[$__rate_interval]))", "legendFormat": "{{gen_ai_request_model}}" } ] @@ -722,24 +722,24 @@ { "current": { "selected": false, "text": "production", "value": "production" }, "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, - "definition": "label_values(http_server_request_duration_milliseconds_count, deployment_environment)", + "definition": "label_values(http_server_request_duration_seconds_count, deployment_environment)", "hide": 0, "includeAll": true, "multi": false, "name": "env", - "query": "label_values(http_server_request_duration_milliseconds_count, deployment_environment)", + "query": "label_values(http_server_request_duration_seconds_count, deployment_environment)", "refresh": 2, "type": "query" }, { "current": { "selected": false, "text": "server", "value": "server" }, "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, - "definition": "label_values(http_server_request_duration_milliseconds_count{deployment_environment=~\"$env\"}, service_name)", + "definition": "label_values(http_server_request_duration_seconds_count{deployment_environment=~\"$env\"}, service_name)", "hide": 0, "includeAll": true, "multi": true, "name": "service", - "query": "label_values(http_server_request_duration_milliseconds_count{deployment_environment=~\"$env\"}, service_name)", + "query": "label_values(http_server_request_duration_seconds_count{deployment_environment=~\"$env\"}, service_name)", "refresh": 2, "type": "query" } diff --git a/apps/server/otel/grafana/dashboards/airi-server-overview.json b/apps/server/otel/grafana/dashboards/airi-server-overview.json index 6f9b4f5e9..8e894a3a7 100644 --- a/apps/server/otel/grafana/dashboards/airi-server-overview.json +++ b/apps/server/otel/grafana/dashboards/airi-server-overview.json @@ -48,7 +48,7 @@ "type": "stat", "targets": [ { - "expr": "sum(rate(airi_http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))", + "expr": "sum(rate(airi_http_server_request_duration_seconds_count{service_name=~\"$service\"}[$__rate_interval]))", "legendFormat": "req/s" } ] @@ -80,7 +80,7 @@ "type": "stat", "targets": [ { - "expr": "sum(rate(airi_http_server_request_duration_milliseconds_count{service_name=~\"$service\", http_status_code=~\"5..\"}[$__rate_interval])) / sum(rate(airi_http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))", + "expr": "sum(rate(airi_http_server_request_duration_seconds_count{service_name=~\"$service\", http_status_code=~\"5..\"}[$__rate_interval])) / sum(rate(airi_http_server_request_duration_seconds_count{service_name=~\"$service\"}[$__rate_interval]))", "legendFormat": "error rate" } ] @@ -94,11 +94,11 @@ "mode": "absolute", "steps": [ { "color": "green", "value": null }, - { "color": "yellow", "value": 200 }, - { "color": "red", "value": 1000 } + { "color": "yellow", "value": 0.2 }, + { "color": "red", "value": 1.0 } ] }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 8, "x": 16, "y": 1 }, @@ -108,11 +108,11 @@ "colorMode": "value", "graphMode": "area" }, - "title": "P95 Latency", + "title": "P95 Latency (excl. LLM)", "type": "stat", "targets": [ { - "expr": "histogram_quantile(0.95, sum(rate(airi_http_server_request_duration_milliseconds_bucket{service_name=~\"$service\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.95, sum(rate(airi_http_server_request_duration_seconds_bucket{service_name=~\"$service\", http_route!~\"/api/v1/openai/.*\"}[$__rate_interval])) by (le))", "legendFormat": "p95" } ] @@ -136,7 +136,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (http_route) (rate(airi_http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))", + "expr": "sum by (http_route) (rate(airi_http_server_request_duration_seconds_count{service_name=~\"$service\"}[$__rate_interval]))", "legendFormat": "{{http_route}}" } ] @@ -150,7 +150,7 @@ "lineInterpolation": "smooth", "fillOpacity": 20 }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 }, @@ -159,7 +159,7 @@ "type": "timeseries", "targets": [ { - "expr": "histogram_quantile(0.95, sum by (le, http_route) (rate(airi_http_server_request_duration_milliseconds_bucket{service_name=~\"$service\"}[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by (le, http_route) (rate(airi_http_server_request_duration_seconds_bucket{service_name=~\"$service\"}[$__rate_interval])))", "legendFormat": "{{http_route}}" } ] @@ -182,7 +182,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (http_status_code) (rate(airi_http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))", + "expr": "sum by (http_status_code) (rate(airi_http_server_request_duration_seconds_count{service_name=~\"$service\"}[$__rate_interval]))", "legendFormat": "{{http_status_code}}" } ] @@ -318,7 +318,7 @@ "lineInterpolation": "smooth", "fillOpacity": 20 }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 43 }, @@ -327,7 +327,7 @@ "type": "timeseries", "targets": [ { - "expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(airi_db_client_operation_duration_milliseconds_bucket{service_name=~\"$service\"}[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(airi_db_client_operation_duration_seconds_bucket{service_name=~\"$service\"}[$__rate_interval])))", "legendFormat": "{{db_operation}}" } ] @@ -350,7 +350,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (db_operation) (rate(airi_db_client_operation_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))", + "expr": "sum by (db_operation) (rate(airi_db_client_operation_duration_seconds_count{service_name=~\"$service\"}[$__rate_interval]))", "legendFormat": "{{db_operation}}" } ] @@ -402,7 +402,7 @@ "lineInterpolation": "smooth", "fillOpacity": 20 }, - "unit": "ms" + "unit": "s" } }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 60 }, @@ -411,7 +411,7 @@ "type": "timeseries", "targets": [ { - "expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(airi_db_client_operation_duration_milliseconds_bucket{service_name=~\"$service\", db_system=\"redis\"}[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(airi_db_client_operation_duration_seconds_bucket{service_name=~\"$service\", db_system=\"redis\"}[$__rate_interval])))", "legendFormat": "{{db_operation}}" } ] @@ -434,7 +434,7 @@ "type": "timeseries", "targets": [ { - "expr": "sum by (db_operation) (rate(airi_db_client_operation_duration_milliseconds_count{service_name=~\"$service\", db_system=\"redis\"}[$__rate_interval]))", + "expr": "sum by (db_operation) (rate(airi_db_client_operation_duration_seconds_count{service_name=~\"$service\", db_system=\"redis\"}[$__rate_interval]))", "legendFormat": "{{db_operation}}" } ] @@ -486,12 +486,12 @@ { "current": { "selected": false, "text": "airi-server", "value": "airi-server" }, "datasource": { "type": "prometheus", "uid": "${datasource}" }, - "definition": "label_values(airi_http_server_request_duration_milliseconds_count, service_name)", + "definition": "label_values(airi_http_server_request_duration_seconds_count, service_name)", "hide": 0, "includeAll": true, "multi": true, "name": "service", - "query": "label_values(airi_http_server_request_duration_milliseconds_count, service_name)", + "query": "label_values(airi_http_server_request_duration_seconds_count, service_name)", "refresh": 2, "type": "query" } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d78a611fb..2121f849f 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -168,7 +168,7 @@ async function buildApp(deps: AppDeps) { /** * V1 routes for official provider. */ - .route('/api/v1/openai', createV1CompletionsRoutes(deps.fluxService, deps.billingService, deps.configKV, deps.billingMq, deps.otel?.llm)) + .route('/api/v1/openai', createV1CompletionsRoutes(deps.fluxService, deps.billingService, deps.configKV, deps.billingMq, deps.otel?.genAi)) /** * Flux routes. diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index cd9ba3d8a..9b88c5d88 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -4,6 +4,7 @@ import type { AuthMetrics } from './otel' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { createAuthMiddleware } from 'better-auth/api' import { bearer } from 'better-auth/plugins' import { getAuthTrustedOrigins } from '../utils/origin' @@ -53,6 +54,32 @@ export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null) }, }, + hooks: { + before: createAuthMiddleware(async (ctx) => { + const isAuthAttempt = ctx.path.includes('/sign-in') || ctx.path.includes('/sign-up') + if (isAuthAttempt) { + metrics?.attempts.add(1, { 'auth.method': ctx.path.split('/').pop() ?? 'unknown' }) + } + }), + after: createAuthMiddleware(async (ctx) => { + // Track auth failures via otel + const isAuthAttempt = ctx.path.includes('/sign-in') || ctx.path.includes('/sign-up') + if (isAuthAttempt && ctx.context.returned && typeof ctx.context.returned === 'object' && 'error' in ctx.context.returned) { + metrics?.failures.add(1, { 'auth.method': ctx.path.split('/').pop() ?? 'unknown' }) + } + + // On OAuth callback errors, redirect back to the referer instead of returning API JSON + if (ctx.path.startsWith('/callback') && ctx.context.returned && typeof ctx.context.returned === 'object' && 'error' in ctx.context.returned) { + const referer = ctx.getHeader('referer') + if (referer) { + const url = new URL(referer) + url.searchParams.set('error', 'auth_failed') + throw ctx.redirect(url.toString()) + } + } + }), + }, + databaseHooks: { user: { create: { diff --git a/apps/server/src/libs/otel.ts b/apps/server/src/libs/otel.ts index 6dd1f29db..8aca9efe9 100644 --- a/apps/server/src/libs/otel.ts +++ b/apps/server/src/libs/otel.ts @@ -23,6 +23,34 @@ import { NodeSDK } from '@opentelemetry/sdk-node' import { BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-node' import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' +import { + 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_OPERATION_COUNT, + METRIC_GEN_AI_CLIENT_OPERATION_DURATION, + METRIC_GEN_AI_CLIENT_TOKEN_USAGE_INPUT, + METRIC_GEN_AI_CLIENT_TOKEN_USAGE_OUTPUT, + METRIC_HTTP_SERVER_ACTIVE_REQUESTS, + METRIC_HTTP_SERVER_REQUEST_DURATION, + METRIC_STRIPE_CHECKOUT_COMPLETED, + METRIC_STRIPE_CHECKOUT_CREATED, + METRIC_STRIPE_EVENTS, + METRIC_STRIPE_PAYMENT_FAILED, + METRIC_STRIPE_SUBSCRIPTION_EVENT, + METRIC_USER_ACTIVE_SESSIONS, + METRIC_USER_LOGIN, + METRIC_USER_REGISTERED, + METRIC_WS_CONNECTIONS_ACTIVE, + METRIC_WS_MESSAGES_RECEIVED, + METRIC_WS_MESSAGES_SENT, +} from '../utils/observability' + const logger = useLogger('otel') export interface HttpMetrics { @@ -57,18 +85,18 @@ export interface RevenueMetrics { fluxInsufficientBalance: Counter } -export interface LlmMetrics { - requestDuration: Histogram - requestCount: Counter - tokensPrompt: Counter - tokensCompletion: Counter +export interface GenAiMetrics { + operationDuration: Histogram + operationCount: Counter + tokenUsageInput: Counter + tokenUsageOutput: Counter fluxConsumed: Counter } -export interface DbMetrics { - queryDuration: Histogram - redisCommandDuration: Histogram -} +// NOTICE: Database metrics (db.client.operation.duration, redis.client.command.duration) were +// intentionally removed. PgInstrumentation and IORedisInstrumentation already generate spans +// with timing for every query/command. To surface these as metrics in Grafana, configure the +// OTel Collector's spanmetrics connector to derive metrics from those spans. export interface OtelInstance { sdk: NodeSDK @@ -76,8 +104,7 @@ export interface OtelInstance { auth: AuthMetrics engagement: EngagementMetrics revenue: RevenueMetrics - llm: LlmMetrics - db: DbMetrics + genAi: GenAiMetrics shutdown: () => Promise } @@ -168,115 +195,103 @@ export function initOtel(env: Env): OtelInstance | undefined { const meter = metrics.getMeter(serviceName) - // HTTP metrics + // HTTP metrics (semconv: unit MUST be seconds) const http: HttpMetrics = { - requestDuration: meter.createHistogram('http.server.request.duration', { - description: 'HTTP server request duration in milliseconds', - unit: 'ms', + requestDuration: meter.createHistogram(METRIC_HTTP_SERVER_REQUEST_DURATION, { + description: 'HTTP server request duration', + unit: 's', }), - activeRequests: meter.createUpDownCounter('http.server.active_requests', { + activeRequests: meter.createUpDownCounter(METRIC_HTTP_SERVER_ACTIVE_REQUESTS, { description: 'Number of active HTTP requests', }), } // Auth & User metrics const auth: AuthMetrics = { - attempts: meter.createCounter('auth.attempts', { + attempts: meter.createCounter(METRIC_AUTH_ATTEMPTS, { description: 'Number of authentication attempts', }), - failures: meter.createCounter('auth.failures', { + failures: meter.createCounter(METRIC_AUTH_FAILURES, { description: 'Number of failed authentication attempts', }), - userRegistered: meter.createCounter('user.registered', { + userRegistered: meter.createCounter(METRIC_USER_REGISTERED, { description: 'Number of new user registrations', }), - userLogin: meter.createCounter('user.login', { + userLogin: meter.createCounter(METRIC_USER_LOGIN, { description: 'Number of user logins', }), - activeSessions: meter.createUpDownCounter('user.active_sessions', { + activeSessions: meter.createUpDownCounter(METRIC_USER_ACTIVE_SESSIONS, { description: 'Number of active user sessions', }), } // Engagement metrics const engagement: EngagementMetrics = { - chatMessages: meter.createCounter('chat.messages', { + chatMessages: meter.createCounter(METRIC_CHAT_MESSAGES, { description: 'Number of chat messages written or pulled', }), - characterCreated: meter.createCounter('character.created', { + characterCreated: meter.createCounter(METRIC_CHARACTER_CREATED, { description: 'Number of characters created', }), - characterDeleted: meter.createCounter('character.deleted', { + characterDeleted: meter.createCounter(METRIC_CHARACTER_DELETED, { description: 'Number of characters deleted', }), - characterEngagement: meter.createCounter('character.engagement', { + characterEngagement: meter.createCounter(METRIC_CHARACTER_ENGAGEMENT, { description: 'Number of character engagement actions (like/bookmark)', }), - wsConnectionsActive: meter.createUpDownCounter('ws.connections.active', { + wsConnectionsActive: meter.createUpDownCounter(METRIC_WS_CONNECTIONS_ACTIVE, { description: 'Active WebSocket connections', }), - wsMessagesSent: meter.createCounter('ws.messages.sent', { + wsMessagesSent: meter.createCounter(METRIC_WS_MESSAGES_SENT, { description: 'Messages sent via WebSocket', }), - wsMessagesReceived: meter.createCounter('ws.messages.received', { + wsMessagesReceived: meter.createCounter(METRIC_WS_MESSAGES_RECEIVED, { description: 'Messages received via WebSocket', }), } // Revenue metrics const revenue: RevenueMetrics = { - stripeCheckoutCreated: meter.createCounter('stripe.checkout.created', { + stripeCheckoutCreated: meter.createCounter(METRIC_STRIPE_CHECKOUT_CREATED, { description: 'Number of Stripe checkout sessions created', }), - stripeCheckoutCompleted: meter.createCounter('stripe.checkout.completed', { + stripeCheckoutCompleted: meter.createCounter(METRIC_STRIPE_CHECKOUT_COMPLETED, { description: 'Number of Stripe checkout sessions completed', }), - stripePaymentFailed: meter.createCounter('stripe.payment.failed', { + stripePaymentFailed: meter.createCounter(METRIC_STRIPE_PAYMENT_FAILED, { description: 'Number of failed Stripe payments', }), - stripeSubscriptionEvent: meter.createCounter('stripe.subscription.event', { + stripeSubscriptionEvent: meter.createCounter(METRIC_STRIPE_SUBSCRIPTION_EVENT, { description: 'Number of Stripe subscription lifecycle events', }), - stripeEvents: meter.createCounter('stripe.events', { + stripeEvents: meter.createCounter(METRIC_STRIPE_EVENTS, { description: 'Number of Stripe webhook events processed', }), - fluxInsufficientBalance: meter.createCounter('flux.insufficient_balance', { + fluxInsufficientBalance: meter.createCounter(METRIC_FLUX_INSUFFICIENT_BALANCE, { description: 'Number of insufficient flux balance errors', }), } - // LLM / Gateway metrics - const llm: LlmMetrics = { - requestDuration: meter.createHistogram('llm.request.duration', { - description: 'LLM gateway request duration in milliseconds', - unit: 'ms', + // 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', }), - requestCount: meter.createCounter('llm.request.count', { - description: 'Number of LLM gateway requests', + operationCount: meter.createCounter(METRIC_GEN_AI_CLIENT_OPERATION_COUNT, { + description: 'Number of GenAI client operations', }), - tokensPrompt: meter.createCounter('llm.tokens.prompt', { - description: 'Total prompt tokens consumed', + tokenUsageInput: meter.createCounter(METRIC_GEN_AI_CLIENT_TOKEN_USAGE_INPUT, { + description: 'Total input (prompt) tokens consumed', }), - tokensCompletion: meter.createCounter('llm.tokens.completion', { - description: 'Total completion tokens consumed', + tokenUsageOutput: meter.createCounter(METRIC_GEN_AI_CLIENT_TOKEN_USAGE_OUTPUT, { + description: 'Total output (completion) tokens consumed', }), - fluxConsumed: meter.createCounter('flux.consumed', { + fluxConsumed: meter.createCounter(METRIC_FLUX_CONSUMED, { description: 'Total flux consumed', }), } - // Database metrics - const db: DbMetrics = { - queryDuration: meter.createHistogram('db.client.operation.duration', { - description: 'Database operation duration in milliseconds', - unit: 'ms', - }), - redisCommandDuration: meter.createHistogram('redis.client.command.duration', { - description: 'Redis command duration in milliseconds', - unit: 'ms', - }), - } - // Graceful shutdown const shutdown = async () => { try { @@ -294,8 +309,7 @@ export function initOtel(env: Env): OtelInstance | undefined { auth, engagement, revenue, - llm, - db, + genAi, shutdown, } } diff --git a/apps/server/src/middlewares/otel.ts b/apps/server/src/middlewares/otel.ts index 3c57e8376..4dc6761d9 100644 --- a/apps/server/src/middlewares/otel.ts +++ b/apps/server/src/middlewares/otel.ts @@ -39,7 +39,7 @@ export function otelMiddleware(http: HttpMetrics): MiddlewareHandler { span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${status}` }) } - http.requestDuration.record(performance.now() - startTime, { + http.requestDuration.record((performance.now() - startTime) / 1000, { 'http.request.method': method, 'http.route': path, 'http.response.status_code': status, diff --git a/apps/server/src/routes/openai/v1/index.ts b/apps/server/src/routes/openai/v1/index.ts index b88b06d25..ef5686322 100644 --- a/apps/server/src/routes/openai/v1/index.ts +++ b/apps/server/src/routes/openai/v1/index.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono' import type { MqService } from '../../../libs/mq' -import type { LlmMetrics } from '../../../libs/otel' +import type { GenAiMetrics } from '../../../libs/otel' import type { UsageInfo } from '../../../services/billing/billing' import type { BillingEvent } from '../../../services/billing/billing-events' import type { BillingService } from '../../../services/billing/billing-service' @@ -69,22 +69,22 @@ function getLlmMetricAttributes(opts: { model: string, type: string, status: num } } -export function createV1CompletionsRoutes(fluxService: FluxService, billingService: BillingService, configKV: ConfigKVService, billingMq: MqService, llm?: LlmMetrics | null) { +export function createV1CompletionsRoutes(fluxService: FluxService, billingService: BillingService, configKV: ConfigKVService, billingMq: MqService, genAi?: GenAiMetrics | null) { const logger = useLogger('v1-completions').useGlobalConfig() // TODO: Extract this compat route into smaller facades/modules. // It currently mixes auth, rate limiting, proxying, billing, telemetry, and event publishing in one transport layer entrypoint. function recordMetrics(opts: { model: string, status: number, type: string, durationMs: number, fluxConsumed: number, promptTokens?: number, completionTokens?: number }) { - if (!llm) + if (!genAi) return const attrs = getLlmMetricAttributes(opts) - llm.requestCount.add(1, attrs) - llm.requestDuration.record(opts.durationMs, attrs) - llm.fluxConsumed.add(opts.fluxConsumed, attrs) + genAi.operationCount.add(1, attrs) + genAi.operationDuration.record(opts.durationMs / 1000, attrs) + genAi.fluxConsumed.add(opts.fluxConsumed, attrs) if (opts.promptTokens != null) - llm.tokensPrompt.add(opts.promptTokens, attrs) + genAi.tokenUsageInput.add(opts.promptTokens, attrs) if (opts.completionTokens != null) - llm.tokensCompletion.add(opts.completionTokens, attrs) + genAi.tokenUsageOutput.add(opts.completionTokens, attrs) } function publishRequestLog(entry: { userId: string, model: string, status: number, durationMs: number, fluxConsumed: number, promptTokens?: number, completionTokens?: number }) { diff --git a/apps/server/src/utils/observability.ts b/apps/server/src/utils/observability.ts index 6b4844d8e..109de0201 100644 --- a/apps/server/src/utils/observability.ts +++ b/apps/server/src/utils/observability.ts @@ -1,8 +1,14 @@ +// --------------------------------------------------------------------------- +// Span attribute constants (OTel semantic conventions + AIRI custom) +// --------------------------------------------------------------------------- + +// GenAI semconv attributes — https://opentelemetry.io/docs/specs/semconv/gen-ai/ export const GEN_AI_ATTR_OPERATION_NAME = 'gen_ai.operation.name' export const GEN_AI_ATTR_REQUEST_MODEL = 'gen_ai.request.model' export const GEN_AI_ATTR_USAGE_INPUT_TOKENS = 'gen_ai.usage.input_tokens' export const GEN_AI_ATTR_USAGE_OUTPUT_TOKENS = 'gen_ai.usage.output_tokens' +// AIRI custom span attributes export const AIRI_ATTR_BILLING_FLUX_CONSUMED = 'airi.billing.flux_consumed' export const AIRI_ATTR_GEN_AI_INPUT_MESSAGES = 'airi.gen_ai.input.messages' export const AIRI_ATTR_GEN_AI_INPUT_TEXT = 'airi.gen_ai.input.text' @@ -13,9 +19,53 @@ export const AIRI_ATTR_GEN_AI_OUTPUT_TEXT = 'airi.gen_ai.output.text' export const AIRI_ATTR_GEN_AI_STREAM = 'airi.gen_ai.stream' export const AIRI_ATTR_GEN_AI_STREAM_INTERRUPTED = 'airi.gen_ai.stream_interrupted' +// Server attributes export const SERVER_ATTR_ADDRESS = 'server.address' export const SERVER_ATTR_PORT = 'server.port' +// --------------------------------------------------------------------------- +// Metric name constants +// --------------------------------------------------------------------------- + +// HTTP — https://opentelemetry.io/docs/specs/semconv/http/http-metrics/ +export const METRIC_HTTP_SERVER_REQUEST_DURATION = 'http.server.request.duration' +export const METRIC_HTTP_SERVER_ACTIVE_REQUESTS = 'http.server.active_requests' + +// Auth & user (AIRI custom) +export const METRIC_AUTH_ATTEMPTS = 'auth.attempts' +export const METRIC_AUTH_FAILURES = 'auth.failures' +export const METRIC_USER_REGISTERED = 'user.registered' +export const METRIC_USER_LOGIN = 'user.login' +export const METRIC_USER_ACTIVE_SESSIONS = 'user.active_sessions' + +// Engagement (AIRI custom) +export const METRIC_CHAT_MESSAGES = 'chat.messages' +export const METRIC_CHARACTER_CREATED = 'character.created' +export const METRIC_CHARACTER_DELETED = 'character.deleted' +export const METRIC_CHARACTER_ENGAGEMENT = 'character.engagement' +export const METRIC_WS_CONNECTIONS_ACTIVE = 'ws.connections.active' +export const METRIC_WS_MESSAGES_SENT = 'ws.messages.sent' +export const METRIC_WS_MESSAGES_RECEIVED = 'ws.messages.received' + +// Revenue (AIRI custom) +export const METRIC_STRIPE_CHECKOUT_CREATED = 'stripe.checkout.created' +export const METRIC_STRIPE_CHECKOUT_COMPLETED = 'stripe.checkout.completed' +export const METRIC_STRIPE_PAYMENT_FAILED = 'stripe.payment.failed' +export const METRIC_STRIPE_SUBSCRIPTION_EVENT = 'stripe.subscription.event' +export const METRIC_STRIPE_EVENTS = 'stripe.events' +export const METRIC_FLUX_INSUFFICIENT_BALANCE = 'flux.insufficient_balance' + +// GenAI — https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/ +export const METRIC_GEN_AI_CLIENT_OPERATION_DURATION = 'gen_ai.client.operation.duration' +export const METRIC_GEN_AI_CLIENT_OPERATION_COUNT = 'gen_ai.client.operation.count' +export const METRIC_GEN_AI_CLIENT_TOKEN_USAGE_INPUT = 'gen_ai.client.token.usage.input' +export const METRIC_GEN_AI_CLIENT_TOKEN_USAGE_OUTPUT = 'gen_ai.client.token.usage.output' +export const METRIC_FLUX_CONSUMED = 'airi.billing.flux.consumed' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + export function getServerConnectionAttributes(baseUrl: string): Record { const url = new URL(baseUrl) const attributes: Record = {