diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index a7433f314..53e89beea 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -21,6 +21,13 @@ DB_READONLY_USER=readonly DB_TRUST_SERVER_CERTIFICATE=true NEXT_TELEMETRY_DISABLED=1 NODE_ENV=development +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-lgtm:4318 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_GRAFANA_HOST_PORT=3300 +OTEL_GRPC_HOST_PORT=4317 +OTEL_HTTP_HOST_PORT=4318 +OTEL_SDK_ENABLED=true +OTEL_SERVICE_NAME=kravhantering # Optional explicit overrides for the derived DB_* connection settings above: # DATABASE_URL=mssql://sa:YourStrong!Passw0rd@db:1433/kravhantering?encrypt=true&trustServerCertificate=true diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 476d7d4b4..17ee34eb9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -180,7 +180,7 @@ }, // Use 'forwardPorts' to make a list of ports inside the container available locally - "forwardPorts": [3000, 3001, 4443, 1433, 8080, 9323, 51204], + "forwardPorts": [3000, 3001, 3300, 4317, 4318, 4443, 1433, 8080, 9323, 51204], "portsAttributes": { "3000": { "label": "Next.js Dev Server 1", @@ -190,6 +190,18 @@ "label": "Next.js Dev Server 2", "onAutoForward": "notify" }, + "3300": { + "label": "Grafana LGTM", + "onAutoForward": "notify" + }, + "4317": { + "label": "OpenTelemetry OTLP gRPC", + "onAutoForward": "silent" + }, + "4318": { + "label": "OpenTelemetry OTLP HTTP", + "onAutoForward": "silent" + }, "4443": { "label": "Next.js Dev HTTPS", "protocol": "https", diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 83cb99780..939d5b244 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -35,6 +35,10 @@ services: DB_TRUST_SERVER_CERTIFICATE: ${DB_TRUST_SERVER_CERTIFICATE:-true} NODE_ENV: ${NODE_ENV:-development} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-1} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-lgtm:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_SDK_ENABLED: ${OTEL_SDK_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-kravhantering} # Ports are forwarded via devcontainer.json forwardPorts (VS Code handles this) # Proper init system for signal handling and zombie process reaping init: true @@ -87,6 +91,16 @@ services: volumes: - ../dev/keycloak:/opt/keycloak/data/import:ro + otel-lgtm: + image: grafana/otel-lgtm:latest + ports: + - "${OTEL_GRAFANA_HOST_PORT:-3300}:3000" + - "${OTEL_GRPC_HOST_PORT:-4317}:4317" + - "${OTEL_HTTP_HOST_PORT:-4318}:4318" + volumes: + - ../dev/grafana/provisioning/dashboard-provider.yml:/otel-lgtm/grafana/conf/provisioning/dashboards/kravhantering.yml:ro + - ../dev/grafana/provisioning/dashboards:/otel-lgtm/grafana/conf/provisioning/dashboards/custom:ro + volumes: sqlserver-devcontainer-data: codex-tmp: diff --git a/.devcontainer/elevated/devcontainer.json b/.devcontainer/elevated/devcontainer.json index 8824d208d..a6e0de4d9 100644 --- a/.devcontainer/elevated/devcontainer.json +++ b/.devcontainer/elevated/devcontainer.json @@ -180,7 +180,7 @@ }, // Use 'forwardPorts' to make a list of ports inside the container available locally - "forwardPorts": [3000, 3001, 4443, 1433, 8080, 9323, 51204], + "forwardPorts": [3000, 3001, 3300, 4317, 4318, 4443, 1433, 8080, 9323, 51204], "portsAttributes": { "3000": { "label": "Next.js Dev Server 1", @@ -190,6 +190,18 @@ "label": "Next.js Dev Server 2", "onAutoForward": "notify" }, + "3300": { + "label": "Grafana LGTM", + "onAutoForward": "notify" + }, + "4317": { + "label": "OpenTelemetry OTLP gRPC", + "onAutoForward": "silent" + }, + "4318": { + "label": "OpenTelemetry OTLP HTTP", + "onAutoForward": "silent" + }, "4443": { "label": "Next.js Dev HTTPS", "protocol": "https", diff --git a/.devcontainer/elevated/docker-compose.yml b/.devcontainer/elevated/docker-compose.yml index 98ddfb9f2..eb0d1b484 100644 --- a/.devcontainer/elevated/docker-compose.yml +++ b/.devcontainer/elevated/docker-compose.yml @@ -34,6 +34,10 @@ services: DB_TRUST_SERVER_CERTIFICATE: ${DB_TRUST_SERVER_CERTIFICATE:-true} NODE_ENV: ${NODE_ENV:-development} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-1} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-lgtm:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_SDK_ENABLED: ${OTEL_SDK_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-kravhantering} # WARNING: This profile grants elevated privileges (SYS_ADMIN, # seccomp=unconfined) and is intended only for trusted code. # It must remain opt-in — do not use as the default configuration. @@ -101,6 +105,16 @@ services: volumes: - ../../dev/keycloak:/opt/keycloak/data/import:ro + otel-lgtm: + image: grafana/otel-lgtm:latest + ports: + - "${OTEL_GRAFANA_HOST_PORT:-3300}:3000" + - "${OTEL_GRPC_HOST_PORT:-4317}:4317" + - "${OTEL_HTTP_HOST_PORT:-4318}:4318" + volumes: + - ../../dev/grafana/provisioning/dashboard-provider.yml:/otel-lgtm/grafana/conf/provisioning/dashboards/kravhantering.yml:ro + - ../../dev/grafana/provisioning/dashboards:/otel-lgtm/grafana/conf/provisioning/dashboards/custom:ro + volumes: sqlserver-elevated-devcontainer-data: codex-tmp: diff --git a/.env.development b/.env.development index 3ef8c3ac9..5e5abed36 100644 --- a/.env.development +++ b/.env.development @@ -25,6 +25,17 @@ MSSQL_SA_PASSWORD=YourStrong!Passw0rd NEXTJS_ENV=development NEXT_PUBLIC_SITE_URL=http://localhost:3000 +# Local OpenTelemetry defaults. The SDK only starts when +# OTEL_SDK_ENABLED=true and an OTLP endpoint is configured. +# This file is host-oriented, so it targets the LGTM host port. The +# devcontainer compose files inject http://otel-lgtm:4318 for the app +# container, matching DB_HOST=db inside the compose network. +CAPACITY_JSON_LOGS_ENABLED=true +OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_SDK_ENABLED=true +OTEL_SERVICE_NAME=kravhantering + AUTH_OIDC_CLIENT_ID=kravhantering-app AUTH_OIDC_CLIENT_SECRET=dev-only-app-secret # -------------------------------------- diff --git a/.env.example b/.env.example index 29adf2e26..19158aeea 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,23 @@ OPENROUTER_MGMT_API_KEY= # Example: anthropic/claude-sonnet-4 NEXT_PUBLIC_DEFAULT_MODEL= +# -------------------------------------- +# OpenTelemetry capacity observability +# -------------------------------------- + +# OTel is disabled unless this is exactly true and an OTLP endpoint is set. +# Host-based development can use docker-compose.otel.yml with 127.0.0.1. +# Inside the devcontainer/Docker Compose network, use http://otel-lgtm:4318. +# Deployments must opt in per environment with their platform collector endpoint. +OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_SDK_ENABLED=false +OTEL_SERVICE_NAME=kravhantering + +# Capacity JSON logs are enabled by default. Set this to false only after +# OTel logs and dashboards have been verified in the target environment. +CAPACITY_JSON_LOGS_ENABLED=true + # -------------------------------------- # Authentication (PhenixID OIDC) # -------------------------------------- diff --git a/.env.prodlike b/.env.prodlike index 0181bea7b..4ceecd8e6 100644 --- a/.env.prodlike +++ b/.env.prodlike @@ -15,6 +15,12 @@ DB_TRUST_SERVER_CERTIFICATE=true MSSQL_SA_PASSWORD=YourStrong!Passw0rd NEXT_PUBLIC_SITE_URL=http://localhost:3001 +# Production-like validation mirrors deployment defaults: OTel stays disabled +# unless the environment explicitly opts in. +CAPACITY_JSON_LOGS_ENABLED=true +OTEL_SDK_ENABLED=false +OTEL_SERVICE_NAME=kravhantering + # -------------------------------------- # Authentication (PhenixID OIDC) — prodlike runs with NODE_ENV=production, # which means auth boot validation is strict (cannot disable, all OIDC diff --git a/cspell.jsonc b/cspell.jsonc index 7ade6864e..2cb8edce4 100644 --- a/cspell.jsonc +++ b/cspell.jsonc @@ -344,6 +344,7 @@ "ztna", "pointerup", "pointercancel", + "piechart", "Kravtexter", "Kravtexterna", "viewports", @@ -384,6 +385,9 @@ "misroutes", "misalign", "deepseek", + "dedup", + "drilldown", + "drilldowns", "konfidens", "medelkonfidens", "mistralai", @@ -419,6 +423,7 @@ "typeorm", "authorsteward", "resursnära", + "rgba", "routas", "sandboxed", "tokenverifierare", @@ -447,7 +452,9 @@ "kravkontrakt", "SARIF", "aquasecurity", - "schemathesis" + "schemathesis", + "timepicker", + "traceql" ], "flagWords": [], "ignorePaths": [ diff --git a/dev/grafana/provisioning/dashboard-provider.yml b/dev/grafana/provisioning/dashboard-provider.yml new file mode 100644 index 000000000..5ab7208ff --- /dev/null +++ b/dev/grafana/provisioning/dashboard-provider.yml @@ -0,0 +1,14 @@ +apiVersion: 1 + +providers: + - name: Kravhantering capacity observability + orgId: 1 + folder: Kravhantering + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /otel-lgtm/grafana/conf/provisioning/dashboards/custom + foldersFromFilesStructure: true + diff --git a/dev/grafana/provisioning/dashboards/kravhantering-capacity-observability.json b/dev/grafana/provisioning/dashboards/kravhantering-capacity-observability.json new file mode 100644 index 000000000..d02a29388 --- /dev/null +++ b/dev/grafana/provisioning/dashboards/kravhantering-capacity-observability.json @@ -0,0 +1,519 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "fieldConfig": { + "defaults": { + "unit": "eps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "sum by (event, operation, source, outcome) (rate(kravhantering_capacity_events_total[$__rate_interval]))", + "legendFormat": "{{event}} / {{operation}} / {{outcome}}", + "range": true, + "refId": "A" + } + ], + "title": "Capacity Event Volume", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "fieldConfig": { + "defaults": { + "unit": "eps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "sum by (outcome, event) (rate(kravhantering_capacity_events_total{outcome=~\"failure|throttled\"}[$__rate_interval]))", + "legendFormat": "{{outcome}} / {{event}}", + "range": true, + "refId": "A" + } + ], + "title": "Failures And Throttles", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum by (le, operation) (rate(kravhantering_capacity_duration_bucket[$__rate_interval])))", + "legendFormat": "p50 {{operation}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by (le, operation) (rate(kravhantering_capacity_duration_bucket[$__rate_interval])))", + "legendFormat": "p95 {{operation}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, sum by (le, operation) (rate(kravhantering_capacity_duration_bucket[$__rate_interval])))", + "legendFormat": "p99 {{operation}}", + "range": true, + "refId": "C" + } + ], + "title": "Operation Duration Percentiles", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 16 + }, + "id": 4, + "options": { + "displayLabels": ["name", "value"], + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "sum by (status_code) (increase(kravhantering_capacity_events_total[$__range]))", + "legendFormat": "{{status_code}}", + "range": true, + "refId": "A" + } + ], + "title": "Status Code Distribution", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 16, + "x": 8, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "sum by (operation) (rate(kravhantering_capacity_item_count_sum[$__rate_interval]))", + "legendFormat": "items {{operation}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "sum by (operation) (rate(kravhantering_capacity_image_count_sum[$__rate_interval]))", + "legendFormat": "images {{operation}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "sum by (operation) (rate(kravhantering_capacity_image_bytes_sum[$__rate_interval]))", + "legendFormat": "image bytes {{operation}}", + "range": true, + "refId": "C" + } + ], + "title": "Item And Image Volume", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "sum by (operation) (rate(kravhantering_capacity_token_count_sum[$__rate_interval]))", + "legendFormat": "tokens {{operation}}", + "range": true, + "refId": "A" + } + ], + "title": "Token Trend", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 7, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${prometheus}" + }, + "editorMode": "code", + "expr": "sum by (operation) (rate(kravhantering_capacity_cost_sum[$__rate_interval]))", + "legendFormat": "cost {{operation}}", + "range": true, + "refId": "A" + } + ], + "title": "Cost Trend", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 8, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, + "editorMode": "code", + "expr": "{service_name=\"kravhantering\"} |~ \"capacity\\\\.\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "Recent Capacity Logs", + "type": "logs" + }, + { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 9, + "targets": [ + { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, + "limit": 20, + "query": "{ resource.service.name = \"kravhantering\" && name =~ \"capacity.*\" }", + "queryType": "traceql", + "refId": "A" + } + ], + "title": "Capacity Trace Drilldown", + "type": "traces" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "tags": ["kravhantering", "capacity", "opentelemetry"], + "templating": { + "list": [ + { + "current": { + "text": "Prometheus", + "value": "Prometheus" + }, + "hide": 0, + "includeAll": false, + "label": "Prometheus", + "multi": false, + "name": "prometheus", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "text": "Loki", + "value": "Loki" + }, + "hide": 0, + "includeAll": false, + "label": "Loki", + "multi": false, + "name": "loki", + "options": [], + "query": "loki", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "text": "Tempo", + "value": "Tempo" + }, + "hide": 0, + "includeAll": false, + "label": "Tempo", + "multi": false, + "name": "tempo", + "options": [], + "query": "tempo", + "refresh": 1, + "regex": "", + "type": "datasource" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Kravhantering Capacity Observability", + "uid": "kravhantering-capacity-observability", + "version": 1, + "weekStart": "" +} diff --git a/docker-compose.otel.yml b/docker-compose.otel.yml new file mode 100644 index 000000000..dfba663f6 --- /dev/null +++ b/docker-compose.otel.yml @@ -0,0 +1,13 @@ +name: kravhantering-otel-dev + +services: + otel-lgtm: + image: grafana/otel-lgtm:latest + ports: + - "${OTEL_GRAFANA_HOST_PORT:-3300}:3000" + - "${OTEL_GRPC_HOST_PORT:-4317}:4317" + - "${OTEL_HTTP_HOST_PORT:-4318}:4318" + volumes: + - ./dev/grafana/provisioning/dashboard-provider.yml:/otel-lgtm/grafana/conf/provisioning/dashboards/kravhantering.yml:ro + - ./dev/grafana/provisioning/dashboards:/otel-lgtm/grafana/conf/provisioning/dashboards/custom:ro + diff --git a/docs/arkitekturbeskrivning-kravhantering.md b/docs/arkitekturbeskrivning-kravhantering.md index aaccbd63f..faece250f 100644 --- a/docs/arkitekturbeskrivning-kravhantering.md +++ b/docs/arkitekturbeskrivning-kravhantering.md @@ -1472,6 +1472,14 @@ oavsett om körningen sker lokalt, i CI eller senare i OpenShift. (millisekunder), status och säkra volymmått. Loggplattformen ansvarar för dashboards, larm, retention och vidare analys. +- OpenTelemetry-export för kapacitetsdata är + avstängd som standard och kräver + `OTEL_SDK_ENABLED=true` samt OTLP-endpoint per + miljö. När den är aktiverad skickas samma + sanerade kapacitetsdata som traces, metrics och + log records. JSON-strömmen är fortsatt aktiv som + fallback om inte `CAPACITY_JSON_LOGS_ENABLED=false` + sätts efter verifierad OTel-insamling. - Säkerhetsrelaterade auth-händelser skrivs som en separat JSON-ström märkt `channel: "security-audit"`, vilket gör att diff --git a/docs/capacity-management.md b/docs/capacity-management.md index 867c354bb..a43f8d093 100644 --- a/docs/capacity-management.md +++ b/docs/capacity-management.md @@ -1,16 +1,15 @@ # Capacity Management - + This document describes the v1 support for capacity measurements, alerting -signals, and throttling in Kravhantering. The implementation addresses action -5 by writing structured JSON events that an external logging, SIEM, or APM -service can collect through the platform log pipeline. +signals, throttling, and optional OpenTelemetry export in Kravhantering. The +default path writes structured JSON events that an external logging, SIEM, or +APM service can collect through the platform log pipeline. ## Event Flow -The application does not send events directly to an external service. Instead, -it writes one JSON line per event to stdout or stderr: +By default, the application writes one JSON line per event to stdout or stderr: ```json { @@ -34,6 +33,80 @@ The external service should filter on dashboards, alerts, and retention policies. Security audit events remain on the separate `security-audit` channel. +OpenTelemetry export is disabled unless `OTEL_SDK_ENABLED=true` and an OTLP +endpoint is configured. When enabled, the same sanitized capacity payload is +also exported as: + +- a short span named `capacity ` +- metrics under `kravhantering.capacity.*` +- a LogRecord for the capacity event + +The JavaScript OpenTelemetry Logs Bridge package is isolated to the capacity +adapter because it is still documented as alpha upstream. JSON logs remain the +stable fallback and are still enabled when OTel is enabled. + +## Environment Configuration + +Development defaults enable OpenTelemetry against the local LGTM stack. Use the +host-mapped endpoint when the Next.js process runs directly on the host: + +```dotenv +OTEL_SDK_ENABLED=true +OTEL_SERVICE_NAME=kravhantering +OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +CAPACITY_JSON_LOGS_ENABLED=true +``` + +When the app runs inside the devcontainer or another Docker Compose network, +use the service name endpoint instead: + +```dotenv +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-lgtm:4318 +``` + +The committed devcontainer compose files inject that container endpoint for the +`app` service, so `.env.development` can stay usable for host-based development. + +Deployments must opt in explicitly. Setting only +`OTEL_EXPORTER_OTLP_ENDPOINT` is not enough; `OTEL_SDK_ENABLED` must be +exactly `true`. + +JSON capacity logs are controlled separately: + +- unset or any value except `false`: JSON logs are written +- `CAPACITY_JSON_LOGS_ENABLED=false`: capacity JSON logs are not written + +Only disable JSON capacity logs after OTel logs and dashboards have been +verified in the target environment. + +## Local Grafana LGTM + +Host-based development can start the local observability stack with: + +```bash +docker compose -f docker-compose.otel.yml up -d +``` + +The devcontainer starts the same `grafana/otel-lgtm` service as part of its +Compose project. The local ports are: + +- Grafana: `http://localhost:3300` +- OTLP gRPC: `localhost:4317` +- OTLP HTTP: `localhost:4318` + +The Grafana dashboard +`Kravhantering Capacity Observability` is provisioned from +`dev/grafana/provisioning/dashboards`. It includes panels for: + +- event volume by event, operation, source, and outcome +- success, failure, threshold, and throttling rates +- p50, p95, and p99 duration by operation +- status code distribution +- item, image, token, and cost trends +- recent capacity LogRecords +- trace drilldown for capacity spans + ## Identifiers - `request_id` identifies one HTTP or MCP request. @@ -70,6 +143,10 @@ Safe metrics may be included when relevant: The capacity log must not contain prompts, requirement text, images, raw query strings, tokens, secrets, or HSA IDs. +OpenTelemetry metrics intentionally do not use `request_id`, +`correlation_id`, or `event_id` as metric labels. Those identifiers are kept on +spans and LogRecords only, to avoid high-cardinality metrics. + ## Measured Flows V1 measures: @@ -109,3 +186,8 @@ SQL Server, Redis, or a platform rate-limiting capability. Operations is responsible for setting environment-specific thresholds and reviewing capacity data at least monthly or before major releases. + +Operations also owns production collector/exporter routing, sampling, dashboard +promotion, and retention. A typical deployment uses `OTEL_TRACES_SAMPLER` and +`OTEL_TRACES_SAMPLER_ARG` to tune trace volume, and collector-side policies for +log retention and downstream export. diff --git a/docs/manual-test-cases.md b/docs/manual-test-cases.md index 8b2c68c44..878a3d38a 100644 --- a/docs/manual-test-cases.md +++ b/docs/manual-test-cases.md @@ -92,6 +92,7 @@ the exact Swedish UI labels used by the seeded Playwright flows. - [DEVTOOLS-01: Developer Mode chip copies a reference](#devtools-01-developer-mode-chip-copies-a-reference) - [DEVTOOLS-02: Developer Mode persists across navigation](#devtools-02-developer-mode-persists-across-navigation) - [DEVTOOLS-03: specification report controls are annotated](#devtools-03-specification-report-controls-are-annotated) + - [OBS-01: local capacity observability dashboard](#obs-01-local-capacity-observability-dashboard) - [RES-01: English admin error recovery](#res-01-english-admin-error-recovery) - [RES-02: homepage smoke](#res-02-homepage-smoke) @@ -1513,6 +1514,34 @@ without pressing the shortcut again. **Expected result:** A chip appears with a useful specification-context reference. +### OBS-01: local capacity observability dashboard + +**Purpose:** Confirm local OpenTelemetry capacity export reaches Grafana LGTM +without losing the JSON fallback path. + +**Users:** `ada.admin`. + +**Prerequisites:** Start LGTM with +`docker compose -f docker-compose.otel.yml up -d`, then start the app with +`npm run dev`. + +**Steps:** + +1. Open `/sv/requirements` and sign in if needed. +1. Open `/sv/specifications/ETJANST-UPP-2026`. +1. Generate a specification list report or detail report. +1. Open `http://localhost:3300`. +1. Open the dashboard `Kravhantering Capacity Observability`. +1. Inspect event volume, duration, status, item, token, cost, log, and trace + panels. +1. Restart the app with `CAPACITY_JSON_LOGS_ENABLED=false npm run dev`. +1. Repeat one report action and inspect the terminal output. + +**Expected result:** The Grafana dashboard shows capacity metrics, LogRecords, +and trace drilldowns for the exercised operation. JSON log lines are visible in +the terminal by default and stop only when `CAPACITY_JSON_LOGS_ENABLED=false` +is set. + ### RES-01: English admin error recovery **Purpose:** Confirm localized admin error recovery works. diff --git a/docs/poc-rhel10-podman.md b/docs/poc-rhel10-podman.md index 6079916a1..fd6edd4df 100644 --- a/docs/poc-rhel10-podman.md +++ b/docs/poc-rhel10-podman.md @@ -1286,6 +1286,20 @@ AUTH_SESSION_COOKIE_PASSWORD= MSSQL_SA_PASSWORD= ``` +OpenTelemetry är avstängt som standard i prodlike- och PoC-körningar. +Lägg bara till följande i `.env.prodlike.local` om PoC-miljön har en +beslutad OTLP-collector och retention för traces, metrics och loggar: + +```bash +OTEL_SDK_ENABLED=true +OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_SERVICE_NAME=kravhantering +``` + +Behåll `CAPACITY_JSON_LOGS_ENABLED=true` tills OTel-insamlingen och +Grafana-vyerna är verifierade i PoC-miljön. + > **Viktigt — `.env.prodlike.local` läses inte automatiskt av > prodlike-skripten.** `build:local-prod` och `start:prodlike` laddar > bara `.env.prodlike` via `dotenv -e`, medan diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 000000000..244c87c42 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,33 @@ +import { + getOpenTelemetryServiceName, + isOpenTelemetryEnabled, +} from './lib/observability/otel-config' + +export async function register(): Promise { + if (process.env.NEXT_RUNTIME === 'edge') return + if (!isOpenTelemetryEnabled()) return + + const [ + { registerOTel }, + { OTLPLogExporter }, + { OTLPMetricExporter }, + { BatchLogRecordProcessor }, + { PeriodicExportingMetricReader }, + ] = await Promise.all([ + import('@vercel/otel'), + import('@opentelemetry/exporter-logs-otlp-http'), + import('@opentelemetry/exporter-metrics-otlp-http'), + import('@opentelemetry/sdk-logs'), + import('@opentelemetry/sdk-metrics'), + ]) + + registerOTel({ + logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter())], + metricReaders: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + ], + serviceName: getOpenTelemetryServiceName(), + }) +} diff --git a/lib/observability/capacity-otel.ts b/lib/observability/capacity-otel.ts new file mode 100644 index 000000000..81811e64c --- /dev/null +++ b/lib/observability/capacity-otel.ts @@ -0,0 +1,204 @@ +import type { + Attributes, + MetricAttributes, + SpanAttributes, +} from '@opentelemetry/api' +import { metrics, SpanStatusCode, trace } from '@opentelemetry/api' +import type { LogAttributes } from '@opentelemetry/api-logs' +import { logs, SeverityNumber } from '@opentelemetry/api-logs' +import { redactSensitiveText } from '@/lib/http/safe-errors' +import { isOpenTelemetryEnabled } from '@/lib/observability/otel-config' + +export type CapacityOtelValue = boolean | number | string | null +export type CapacityOtelPayload = Record + +const tracer = trace.getTracer('kravhantering.capacity') +const meter = metrics.getMeter('kravhantering.capacity') +const logger = logs.getLogger('kravhantering.capacity') + +const eventCounter = meter.createCounter('kravhantering.capacity.events', { + description: 'Capacity observability events emitted by Kravhantering.', + unit: '{event}', +}) +const throttledCounter = meter.createCounter( + 'kravhantering.capacity.throttled', + { + description: 'Capacity events that represent throttled requests.', + unit: '{request}', + }, +) +const durationHistogram = meter.createHistogram( + 'kravhantering.capacity.duration', + { + description: 'Measured operation duration.', + unit: 'ms', + }, +) +const itemCountHistogram = meter.createHistogram( + 'kravhantering.capacity.item_count', + { + description: 'Items processed by measured operations.', + unit: '{item}', + }, +) +const imageCountHistogram = meter.createHistogram( + 'kravhantering.capacity.image_count', + { + description: 'Images processed by measured operations.', + unit: '{image}', + }, +) +const imageBytesHistogram = meter.createHistogram( + 'kravhantering.capacity.image_bytes', + { + description: 'Image bytes processed by measured operations.', + unit: 'By', + }, +) +const tokenCountHistogram = meter.createHistogram( + 'kravhantering.capacity.token_count', + { + description: 'Tokens used by measured operations.', + unit: '{token}', + }, +) +const costHistogram = meter.createHistogram('kravhantering.capacity.cost', { + description: 'Provider cost reported by measured operations.', + unit: 'USD', +}) + +function isAttributeValue( + value: CapacityOtelValue | undefined, +): value is boolean | number | string { + return value !== null && value !== undefined +} + +function asNumber(value: CapacityOtelValue | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function getString(value: CapacityOtelValue | undefined): string { + return typeof value === 'string' && value.trim().length > 0 + ? value + : 'unknown' +} + +function getStatusCodeLabel(value: CapacityOtelValue | undefined): string { + const statusCode = asNumber(value) + if (statusCode === null) return 'unknown' + const normalized = Math.trunc(statusCode) + if (normalized < 100 || normalized > 599) return 'unknown' + return String(normalized) +} + +function getMetricAttributes(payload: CapacityOtelPayload): MetricAttributes { + return { + event: getString(payload.event), + operation: getString(payload.operation), + outcome: getString(payload.outcome), + source: getString(payload.source), + status_code: getStatusCodeLabel(payload.status_code), + } +} + +function getSignalAttributes(payload: CapacityOtelPayload): Attributes { + const attributes: Attributes = {} + for (const [key, value] of Object.entries(payload)) { + if (isAttributeValue(value)) { + attributes[key] = value + } + } + return attributes +} + +function getSeverityNumber(level: CapacityOtelValue | undefined): number { + if (level === 'error') return SeverityNumber.ERROR + if (level === 'warn') return SeverityNumber.WARN + return SeverityNumber.INFO +} + +function getSpanStartAndEnd(payload: CapacityOtelPayload): { + endTime?: Date + startTime?: Date +} { + const durationMs = asNumber(payload.duration_ms) + const ts = + typeof payload.ts === 'string' ? Date.parse(payload.ts) : Number.NaN + if (durationMs === null || !Number.isFinite(ts)) return {} + + return { + endTime: new Date(ts), + startTime: new Date(ts - durationMs), + } +} + +function recordMetrics(payload: CapacityOtelPayload): void { + const attributes = getMetricAttributes(payload) + eventCounter.add(1, attributes) + + if (payload.event === 'capacity.throttled' || payload.throttled === true) { + throttledCounter.add(1, attributes) + } + + const durationMs = asNumber(payload.duration_ms) + const itemCount = asNumber(payload.item_count) + const imageCount = asNumber(payload.image_count) + const imageBytes = asNumber(payload.image_bytes) + const tokenCount = asNumber(payload.token_count) + const cost = asNumber(payload.cost) + + if (durationMs !== null) durationHistogram.record(durationMs, attributes) + if (itemCount !== null) itemCountHistogram.record(itemCount, attributes) + if (imageCount !== null) imageCountHistogram.record(imageCount, attributes) + if (imageBytes !== null) imageBytesHistogram.record(imageBytes, attributes) + if (tokenCount !== null) tokenCountHistogram.record(tokenCount, attributes) + if (cost !== null) costHistogram.record(cost, attributes) +} + +function recordSpan(payload: CapacityOtelPayload): void { + const attributes = getSignalAttributes(payload) as SpanAttributes + const { endTime, startTime } = getSpanStartAndEnd(payload) + const span = tracer.startSpan(`capacity ${getString(payload.operation)}`, { + attributes, + startTime, + }) + span.addEvent(getString(payload.event), attributes) + span.setStatus({ + code: + payload.outcome === 'failure' ? SpanStatusCode.ERROR : SpanStatusCode.OK, + }) + span.end(endTime) +} + +function recordLog(payload: CapacityOtelPayload): void { + const attributes = getSignalAttributes(payload) as LogAttributes + const severityNumber = getSeverityNumber(payload.level) + logger.emit({ + attributes, + body: getString(payload.event), + severityNumber, + severityText: getString(payload.level).toUpperCase(), + }) +} + +export function recordCapacityOtelEvent(payload: CapacityOtelPayload): void { + if (!isOpenTelemetryEnabled()) return + + try { + recordMetrics(payload) + recordSpan(payload) + recordLog(payload) + } catch (error) { + try { + // eslint-disable-next-line no-console + console.error( + '[capacity-observability] failed to export OpenTelemetry event', + redactSensitiveText( + error instanceof Error ? error.message : String(error), + ), + ) + } catch { + /* OpenTelemetry export must never break the request path */ + } + } +} diff --git a/lib/observability/capacity.ts b/lib/observability/capacity.ts index c81a24e5a..773c4b22f 100644 --- a/lib/observability/capacity.ts +++ b/lib/observability/capacity.ts @@ -1,4 +1,5 @@ import { redactSensitiveText } from '@/lib/http/safe-errors' +import { recordCapacityOtelEvent } from '@/lib/observability/capacity-otel' import { type RequestCorrelationIds, resolveRequestCorrelationIds, @@ -79,6 +80,8 @@ function writeCapacityLog( level: CapacityEventLevel, payload: Record, ): void { + if (process.env.CAPACITY_JSON_LOGS_ENABLED === 'false') return + const serialized = JSON.stringify(payload) if (level === 'error') { // eslint-disable-next-line no-console @@ -133,6 +136,7 @@ export function recordCapacityEvent(input: CapacityEventInput): void { payload.throttled = metrics.throttled } + recordCapacityOtelEvent(payload) writeCapacityLog(level, payload) } catch (error) { try { diff --git a/lib/observability/otel-config.ts b/lib/observability/otel-config.ts new file mode 100644 index 000000000..83c1c8204 --- /dev/null +++ b/lib/observability/otel-config.ts @@ -0,0 +1,33 @@ +export interface OpenTelemetryEnv { + OTEL_EXPORTER_OTLP_ENDPOINT?: string + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT?: string + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT?: string + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?: string + OTEL_SDK_ENABLED?: string + OTEL_SERVICE_NAME?: string + [key: string]: string | undefined +} + +export function hasOpenTelemetryEndpoint( + env: OpenTelemetryEnv = process.env, +): boolean { + return [ + env.OTEL_EXPORTER_OTLP_ENDPOINT, + env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + ].some(value => typeof value === 'string' && value.trim().length > 0) +} + +export function isOpenTelemetryEnabled( + env: OpenTelemetryEnv = process.env, +): boolean { + return env.OTEL_SDK_ENABLED === 'true' && hasOpenTelemetryEndpoint(env) +} + +export function getOpenTelemetryServiceName( + env: OpenTelemetryEnv = process.env, +): string { + const serviceName = env.OTEL_SERVICE_NAME?.trim() + return serviceName && serviceName.length > 0 ? serviceName : 'kravhantering' +} diff --git a/package-lock.json b/package-lock.json index 45a133d0c..62c6f4d97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,15 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.218.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0", + "@opentelemetry/sdk-logs": "^0.218.0", + "@opentelemetry/sdk-metrics": "^2.7.1", "@react-pdf/renderer": "^4.5.1", "@tailwindcss/typography": "^0.5.19", + "@vercel/otel": "^2.1.2", "framer-motion": "^12.38.0", "iron-session": "^8.0.4", "jose": "^6.2.3", @@ -2484,6 +2491,210 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", + "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz", + "integrity": "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/sdk-logs": "0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.218.0.tgz", + "integrity": "sha512-bV7d2OuMpZu2+gAaxUAhzfZ0h3WVZk8ETQUEE3DNSntbTaMpuITjtm8I0rNyHFdm7Ax57K6ty7SgFXlBmOLIvQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.218.0.tgz", + "integrity": "sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", + "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-transformer": "0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", + "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", + "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -4288,6 +4499,24 @@ "node": ">=20.0.0" } }, + "node_modules/@vercel/otel": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vercel/otel/-/otel-2.1.2.tgz", + "integrity": "sha512-PbGyq1lLwWbnftylNcQ6KFxc7DLc8LJdnaU3snM43bgXiWkWsHrgaU/LdUD8sHaSgG8UKuydX/aymNt3J+hzuA==", + "license": "MIT", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <2.0.0", + "@opentelemetry/api-logs": ">=0.200.0 <0.300.0", + "@opentelemetry/instrumentation": ">=0.200.0 <0.300.0", + "@opentelemetry/resources": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-logs": ">=0.200.0 <0.300.0", + "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0" + } + }, "node_modules/@viscalyx/developer-mode-core": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@viscalyx/developer-mode-core/-/developer-mode-core-0.2.2.tgz", @@ -4512,6 +4741,29 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -5061,6 +5313,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT", + "peer": true + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -6826,6 +7085,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz", + "integrity": "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -8727,6 +9002,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT", + "peer": true + }, "node_modules/motion-dom": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", @@ -9635,6 +9917,20 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", diff --git a/package.json b/package.json index 065a2b0f6..0450c7ed3 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,15 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.218.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0", + "@opentelemetry/sdk-logs": "^0.218.0", + "@opentelemetry/sdk-metrics": "^2.7.1", "@react-pdf/renderer": "^4.5.1", "@tailwindcss/typography": "^0.5.19", + "@vercel/otel": "^2.1.2", "framer-motion": "^12.38.0", "iron-session": "^8.0.4", "jose": "^6.2.3", diff --git a/tests/unit/capacity-observability.test.ts b/tests/unit/capacity-observability.test.ts index b8955f622..1f46a4b87 100644 --- a/tests/unit/capacity-observability.test.ts +++ b/tests/unit/capacity-observability.test.ts @@ -8,6 +8,7 @@ import { parseCapacityEvents } from '@/tests/helpers/capacity-events' describe('capacity observability', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllEnvs() }) it('writes stable JSON events with correlation metadata', () => { @@ -63,6 +64,24 @@ describe('capacity observability', () => { expect(JSON.stringify(event)).not.toMatch(/sk-or-v1|SELECT token/) }) + it('can disable capacity JSON logs without disabling OTel export', () => { + vi.stubEnv('CAPACITY_JSON_LOGS_ENABLED', 'false') + const infoSpy = vi + .spyOn(console, 'info') + .mockImplementation(() => undefined) + + recordCapacityEvent({ + correlationId: 'workflow-1', + event: 'capacity.operation.completed', + operation: 'reports.specification_items', + outcome: 'success', + requestId: 'request-1', + source: 'rest', + }) + + expect(parseCapacityEvents(infoSpy)).toEqual([]) + }) + it('records operation completion and threshold events', async () => { const infoSpy = vi .spyOn(console, 'info') diff --git a/tests/unit/capacity-otel.test.ts b/tests/unit/capacity-otel.test.ts new file mode 100644 index 000000000..89a0312a3 --- /dev/null +++ b/tests/unit/capacity-otel.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const eventCounterAdd = vi.fn() +const throttledCounterAdd = vi.fn() +const histogramRecord = vi.fn() +const spanAddEvent = vi.fn() +const spanEnd = vi.fn() +const spanSetStatus = vi.fn() +const startSpan = vi.fn() +const logEmit = vi.fn() + +vi.mock('@opentelemetry/api', () => ({ + metrics: { + getMeter: () => ({ + createCounter: (name: string) => ({ + add: + name === 'kravhantering.capacity.throttled' + ? throttledCounterAdd + : eventCounterAdd, + }), + createHistogram: () => ({ + record: histogramRecord, + }), + }), + }, + SpanStatusCode: { + ERROR: 2, + OK: 1, + }, + trace: { + getTracer: () => ({ + startSpan, + }), + }, +})) + +vi.mock('@opentelemetry/api-logs', () => ({ + logs: { + getLogger: () => ({ + emit: logEmit, + }), + }, + SeverityNumber: { + ERROR: 17, + INFO: 9, + WARN: 13, + }, +})) + +describe('capacity OpenTelemetry export', () => { + beforeEach(() => { + startSpan.mockReturnValue({ + addEvent: spanAddEvent, + end: spanEnd, + setStatus: spanSetStatus, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + }) + + it('does not export when OTel is not explicitly enabled', async () => { + vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://127.0.0.1:4318') + const { recordCapacityOtelEvent } = await import( + '@/lib/observability/capacity-otel' + ) + + recordCapacityOtelEvent({ + channel: 'capacity-observability', + correlation_id: 'workflow-1', + event: 'capacity.operation.completed', + event_id: 'event-1', + level: 'info', + operation: 'reports.specification_items', + outcome: 'success', + request_id: 'request-1', + source: 'rest', + ts: '2026-05-17T10:00:00.000Z', + }) + + expect(eventCounterAdd).not.toHaveBeenCalled() + expect(startSpan).not.toHaveBeenCalled() + expect(logEmit).not.toHaveBeenCalled() + }) + + it('exports metrics, spans, and logs from safe capacity payloads', async () => { + vi.stubEnv('OTEL_SDK_ENABLED', 'true') + vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://127.0.0.1:4318') + const { recordCapacityOtelEvent } = await import( + '@/lib/observability/capacity-otel' + ) + + recordCapacityOtelEvent({ + channel: 'capacity-observability', + correlation_id: 'workflow-1', + cost: 0.12, + duration_ms: 42, + event: 'capacity.operation.completed', + event_id: 'event-1', + item_count: 3, + level: 'info', + operation: 'ai.generate-requirements', + outcome: 'success', + request_id: 'request-1', + source: 'rest', + status_code: 200, + token_count: 100, + ts: '2026-05-17T10:00:00.000Z', + }) + + expect(eventCounterAdd).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + event: 'capacity.operation.completed', + operation: 'ai.generate-requirements', + outcome: 'success', + source: 'rest', + status_code: '200', + }), + ) + expect(eventCounterAdd.mock.calls[0]?.[1]).not.toHaveProperty('request_id') + expect(eventCounterAdd.mock.calls[0]?.[1]).not.toHaveProperty( + 'correlation_id', + ) + expect(histogramRecord).toHaveBeenCalledWith( + 42, + expect.objectContaining({ operation: 'ai.generate-requirements' }), + ) + expect(startSpan).toHaveBeenCalledWith( + 'capacity ai.generate-requirements', + expect.objectContaining({ + attributes: expect.objectContaining({ + correlation_id: 'workflow-1', + request_id: 'request-1', + }), + }), + ) + expect(logEmit).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + correlation_id: 'workflow-1', + request_id: 'request-1', + }), + body: 'capacity.operation.completed', + }), + ) + }) +}) diff --git a/tests/unit/otel-config.test.ts b/tests/unit/otel-config.test.ts new file mode 100644 index 000000000..81b259554 --- /dev/null +++ b/tests/unit/otel-config.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + getOpenTelemetryServiceName, + isOpenTelemetryEnabled, +} from '@/lib/observability/otel-config' + +describe('OpenTelemetry config', () => { + it('is disabled by default even when an OTLP endpoint is configured', () => { + expect( + isOpenTelemetryEnabled({ + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://127.0.0.1:4318', + }), + ).toBe(false) + }) + + it('requires OTEL_SDK_ENABLED=true and an OTLP endpoint', () => { + expect( + isOpenTelemetryEnabled({ + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://127.0.0.1:4318', + OTEL_SDK_ENABLED: 'true', + }), + ).toBe(true) + expect(isOpenTelemetryEnabled({ OTEL_SDK_ENABLED: 'true' })).toBe(false) + }) + + it('defaults the service name to kravhantering', () => { + expect(getOpenTelemetryServiceName({})).toBe('kravhantering') + expect( + getOpenTelemetryServiceName({ OTEL_SERVICE_NAME: ' krav-dev ' }), + ).toBe('krav-dev') + }) +})