feat(server): add OpenTelemetry observability stack

This commit is contained in:
RainbowBird
2026-03-06 02:58:44 +08:00
committed by RainbowBird
parent 3f12d714b5
commit 284b100030
16 changed files with 2576 additions and 131 deletions
+2
View File
@@ -5,3 +5,5 @@ AUTH_GOOGLE_CLIENT_SECRET="change-me"
AUTH_GITHUB_CLIENT_ID="change-me"
AUTH_GITHUB_CLIENT_SECRET="change-me"
# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
+122
View File
@@ -0,0 +1,122 @@
name: proj-airi-otel
services:
# ============================================================
# OpenTelemetry Collector
# Receives traces, metrics, and logs from the application
# and exports them to the appropriate backends.
# ============================================================
otel-collector:
image: otel/opentelemetry-collector-contrib:0.120.0
command: ['--config=/etc/otelcol/otel-collector.yaml']
volumes:
- ./otel/collector/otel-collector.yaml:/etc/otelcol/otel-collector.yaml:ro
ports:
- '4317:4317' # OTLP gRPC
- '4318:4318' # OTLP HTTP
depends_on:
loki:
condition: service_started
tempo:
condition: service_started
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:13133/']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# ============================================================
# Prometheus - Metrics storage and querying
# ============================================================
prometheus:
image: prom/prometheus:v3.2.1
command:
- --config.file=/etc/prometheus/prometheus.yaml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --web.enable-remote-write-receiver
- --enable-feature=exemplar-storage
- --enable-feature=native-histograms
volumes:
- ./otel/prometheus/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
- prometheus_data:/prometheus
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:9090/-/healthy']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# ============================================================
# Loki - Log aggregation
# ============================================================
loki:
image: grafana/loki:3.4.3
command: -config.file=/etc/loki/loki.yaml
volumes:
- ./otel/loki/loki.yaml:/etc/loki/loki.yaml:ro
- loki_data:/loki
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3100/ready']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# ============================================================
# Tempo - Distributed tracing backend
# ============================================================
tempo:
image: grafana/tempo:2.7.2
command: ['-config.file=/etc/tempo/tempo.yaml']
volumes:
- ./otel/tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
- tempo_data:/var/tempo
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3200/ready']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# ============================================================
# Grafana - Visualization and dashboards
# ============================================================
grafana:
image: grafana/grafana:11.5.2
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_FEATURE_TOGGLES_ENABLE=traceqlEditor tempoSearch tempoServiceGraph
volumes:
- ./otel/grafana/provisioning:/etc/grafana/provisioning:ro
- ./otel/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana_data:/var/lib/grafana
ports:
- '3001:3000'
depends_on:
prometheus:
condition: service_healthy
loki:
condition: service_healthy
tempo:
condition: service_healthy
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3000/api/health']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
prometheus_data:
driver: local
loki_data:
driver: local
tempo_data:
driver: local
grafana_data:
driver: local
@@ -0,0 +1,88 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
resource:
attributes:
- key: service.namespace
value: proj-airi
action: upsert
# Tail-based sampling: keeps all errors and slow requests,
# samples a percentage of normal traffic to reduce storage costs.
tail_sampling:
decision_wait: 10s
num_traces: 100000
policies:
# Always keep traces that contain errors
- name: errors-policy
type: status_code
status_code:
status_codes: [ERROR]
# Always keep slow requests (> 500ms)
- name: slow-requests-policy
type: latency
latency:
threshold_ms: 500
# Sample 10% of remaining normal traffic
- name: probabilistic-policy
type: probabilistic
probabilistic:
sampling_percentage: 10
exporters:
# Prometheus exporter for metrics
prometheus:
endpoint: 0.0.0.0:8889
namespace: airi
resource_to_telemetry_conversion:
enabled: true
# Loki exporter for logs
loki:
endpoint: http://loki:3100/loki/api/v1/push
# Tempo exporter for traces
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
debug:
verbosity: basic
extensions:
health_check:
endpoint: 0.0.0.0:13133
zpages:
endpoint: 0.0.0.0:55679
service:
extensions: [health_check, zpages]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resource, tail_sampling, batch]
exporters: [otlp/tempo, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [prometheus, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [loki, debug]
telemetry:
logs:
level: info
@@ -0,0 +1,493 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 100,
"title": "HTTP Overview",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 100 },
{ "color": "red", "value": 500 }
]
},
"unit": "reqps"
}
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 1 },
"id": 1,
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto"
},
"title": "Request Rate",
"type": "stat",
"targets": [
{
"expr": "sum(rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"legendFormat": "req/s"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.01 },
{ "color": "red", "value": 0.05 }
]
},
"unit": "percentunit"
}
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 1 },
"id": 2,
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
},
"title": "Error Rate (5xx)",
"type": "stat",
"targets": [
{
"expr": "sum(rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\", http_status_code=~\"5..\"}[$__rate_interval])) / sum(rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"legendFormat": "error rate"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 200 },
{ "color": "red", "value": 1000 }
]
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 1 },
"id": 3,
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
},
"title": "P95 Latency",
"type": "stat",
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_milliseconds_bucket{service_name=~\"$service\"}[$__rate_interval])) by (le))",
"legendFormat": "p95"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20,
"stacking": { "mode": "none" }
},
"unit": "reqps"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 },
"id": 4,
"title": "Request Rate by Route",
"type": "timeseries",
"targets": [
{
"expr": "sum by (http_route) (rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"legendFormat": "{{http_route}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 },
"id": 5,
"title": "Latency by Route (P95)",
"type": "timeseries",
"targets": [
{
"expr": "histogram_quantile(0.95, sum by (le, http_route) (rate(http_server_request_duration_milliseconds_bucket{service_name=~\"$service\"}[$__rate_interval])))",
"legendFormat": "{{http_route}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 80,
"stacking": { "mode": "normal" }
},
"unit": "reqps"
}
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 17 },
"id": 6,
"title": "Response Status Codes",
"type": "timeseries",
"targets": [
{
"expr": "sum by (http_status_code) (rate(http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"legendFormat": "{{http_status_code}}"
}
]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 },
"id": 101,
"title": "Node.js Runtime",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "bytes"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 26 },
"id": 7,
"title": "Heap Memory Usage",
"type": "timeseries",
"targets": [
{
"expr": "process_runtime_nodejs_memory_heap_total{service_name=~\"$service\"}",
"legendFormat": "heap total"
},
{
"expr": "process_runtime_nodejs_memory_heap_used{service_name=~\"$service\"}",
"legendFormat": "heap used"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "bytes"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 26 },
"id": 8,
"title": "RSS & External Memory",
"type": "timeseries",
"targets": [
{
"expr": "process_runtime_nodejs_memory_rss{service_name=~\"$service\"}",
"legendFormat": "RSS"
},
{
"expr": "process_runtime_nodejs_memory_array_buffers{service_name=~\"$service\"}",
"legendFormat": "array buffers"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "short"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 },
"id": 9,
"title": "Event Loop Lag",
"type": "timeseries",
"targets": [
{
"expr": "process_runtime_nodejs_event_loop_lag_p99{service_name=~\"$service\"}",
"legendFormat": "p99 lag"
},
{
"expr": "process_runtime_nodejs_event_loop_lag_p50{service_name=~\"$service\"}",
"legendFormat": "p50 lag"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "short"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 },
"id": 10,
"title": "Active Handles & Requests",
"type": "timeseries",
"targets": [
{
"expr": "process_runtime_nodejs_active_handles_total{service_name=~\"$service\"}",
"legendFormat": "active handles"
},
{
"expr": "process_runtime_nodejs_active_requests_total{service_name=~\"$service\"}",
"legendFormat": "active requests"
}
]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 42 },
"id": 102,
"title": "PostgreSQL",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 43 },
"id": 11,
"title": "DB Query Duration (P95)",
"type": "timeseries",
"targets": [
{
"expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(db_client_operation_duration_milliseconds_bucket{service_name=~\"$service\"}[$__rate_interval])))",
"legendFormat": "{{db_operation}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ops"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 43 },
"id": 12,
"title": "DB Query Rate",
"type": "timeseries",
"targets": [
{
"expr": "sum by (db_operation) (rate(db_client_operation_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"legendFormat": "{{db_operation}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "short"
}
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 51 },
"id": 13,
"title": "DB Connection Pool",
"type": "timeseries",
"targets": [
{
"expr": "db_client_connections_usage{service_name=~\"$service\", state=\"used\"}",
"legendFormat": "used"
},
{
"expr": "db_client_connections_usage{service_name=~\"$service\", state=\"idle\"}",
"legendFormat": "idle"
},
{
"expr": "db_client_connections_pending_requests{service_name=~\"$service\"}",
"legendFormat": "pending"
}
]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 59 },
"id": 103,
"title": "Redis",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 60 },
"id": 14,
"title": "Redis Command Duration (P95)",
"type": "timeseries",
"targets": [
{
"expr": "histogram_quantile(0.95, sum by (le, db_operation) (rate(db_client_operation_duration_milliseconds_bucket{service_name=~\"$service\", db_system=\"redis\"}[$__rate_interval])))",
"legendFormat": "{{db_operation}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ops"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 60 },
"id": 15,
"title": "Redis Command Rate",
"type": "timeseries",
"targets": [
{
"expr": "sum by (db_operation) (rate(db_client_operation_duration_milliseconds_count{service_name=~\"$service\", db_system=\"redis\"}[$__rate_interval]))",
"legendFormat": "{{db_operation}}"
}
]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 68 },
"id": 104,
"title": "Application Logs",
"type": "row"
},
{
"datasource": { "type": "loki", "uid": "grafanacloud-projairi-logs" },
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 69 },
"id": 16,
"title": "Application Logs",
"type": "logs",
"targets": [
{
"expr": "{service_name=~\"$service\"} |= ``",
"legendFormat": ""
}
],
"options": {
"showTime": true,
"showLabels": true,
"showCommonLabels": false,
"wrapLogMessage": true,
"prettifyLogMessage": false,
"enableLogDetails": true,
"sortOrder": "Descending"
}
}
],
"schemaVersion": 39,
"tags": ["airi", "observability", "grafana-cloud"],
"templating": {
"list": [
{
"current": { "selected": false, "text": "server", "value": "server" },
"datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" },
"definition": "label_values(http_server_request_duration_milliseconds_count, service_name)",
"hide": 0,
"includeAll": true,
"multi": true,
"name": "service",
"query": "label_values(http_server_request_duration_milliseconds_count, service_name)",
"refresh": 2,
"type": "query"
}
]
},
"time": { "from": "now-1h", "to": "now" },
"title": "AIRI Server Overview (Cloud)",
"uid": "airi-server-overview-cloud"
}
@@ -0,0 +1,503 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 100,
"title": "HTTP Overview",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 100 },
{ "color": "red", "value": 500 }
]
},
"unit": "reqps"
}
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 1 },
"id": 1,
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto"
},
"title": "Request Rate",
"type": "stat",
"targets": [
{
"expr": "sum(rate(airi_http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"legendFormat": "req/s"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.01 },
{ "color": "red", "value": 0.05 }
]
},
"unit": "percentunit"
}
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 1 },
"id": 2,
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
},
"title": "Error Rate (5xx)",
"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]))",
"legendFormat": "error rate"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 200 },
{ "color": "red", "value": 1000 }
]
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 1 },
"id": 3,
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area"
},
"title": "P95 Latency",
"type": "stat",
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(airi_http_server_request_duration_milliseconds_bucket{service_name=~\"$service\"}[$__rate_interval])) by (le))",
"legendFormat": "p95"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20,
"stacking": { "mode": "none" }
},
"unit": "reqps"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 },
"id": 4,
"title": "Request Rate by Route",
"type": "timeseries",
"targets": [
{
"expr": "sum by (http_route) (rate(airi_http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"legendFormat": "{{http_route}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 },
"id": 5,
"title": "Latency by Route (P95)",
"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])))",
"legendFormat": "{{http_route}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 80,
"stacking": { "mode": "normal" }
},
"unit": "reqps"
}
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 17 },
"id": 6,
"title": "Response Status Codes",
"type": "timeseries",
"targets": [
{
"expr": "sum by (http_status_code) (rate(airi_http_server_request_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"legendFormat": "{{http_status_code}}"
}
]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 },
"id": 101,
"title": "Node.js Runtime",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "bytes"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 26 },
"id": 7,
"title": "Heap Memory Usage",
"type": "timeseries",
"targets": [
{
"expr": "airi_process_runtime_nodejs_memory_heap_total{service_name=~\"$service\"}",
"legendFormat": "heap total"
},
{
"expr": "airi_process_runtime_nodejs_memory_heap_used{service_name=~\"$service\"}",
"legendFormat": "heap used"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "bytes"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 26 },
"id": 8,
"title": "RSS & External Memory",
"type": "timeseries",
"targets": [
{
"expr": "airi_process_runtime_nodejs_memory_rss{service_name=~\"$service\"}",
"legendFormat": "RSS"
},
{
"expr": "airi_process_runtime_nodejs_memory_array_buffers{service_name=~\"$service\"}",
"legendFormat": "array buffers"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "short"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 },
"id": 9,
"title": "Event Loop Lag",
"type": "timeseries",
"targets": [
{
"expr": "airi_process_runtime_nodejs_event_loop_lag_p99{service_name=~\"$service\"}",
"legendFormat": "p99 lag"
},
{
"expr": "airi_process_runtime_nodejs_event_loop_lag_p50{service_name=~\"$service\"}",
"legendFormat": "p50 lag"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "short"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 },
"id": 10,
"title": "Active Handles & Requests",
"type": "timeseries",
"targets": [
{
"expr": "airi_process_runtime_nodejs_active_handles_total{service_name=~\"$service\"}",
"legendFormat": "active handles"
},
{
"expr": "airi_process_runtime_nodejs_active_requests_total{service_name=~\"$service\"}",
"legendFormat": "active requests"
}
]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 42 },
"id": 102,
"title": "PostgreSQL",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 43 },
"id": 11,
"title": "DB Query Duration (P95)",
"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])))",
"legendFormat": "{{db_operation}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ops"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 43 },
"id": 12,
"title": "DB Query Rate",
"type": "timeseries",
"targets": [
{
"expr": "sum by (db_operation) (rate(airi_db_client_operation_duration_milliseconds_count{service_name=~\"$service\"}[$__rate_interval]))",
"legendFormat": "{{db_operation}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "short"
}
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 51 },
"id": 13,
"title": "DB Connection Pool",
"type": "timeseries",
"targets": [
{
"expr": "airi_db_client_connections_usage{service_name=~\"$service\", state=\"used\"}",
"legendFormat": "used"
},
{
"expr": "airi_db_client_connections_usage{service_name=~\"$service\", state=\"idle\"}",
"legendFormat": "idle"
},
{
"expr": "airi_db_client_connections_pending_requests{service_name=~\"$service\"}",
"legendFormat": "pending"
}
]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 59 },
"id": 103,
"title": "Redis",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 60 },
"id": 14,
"title": "Redis Command Duration (P95)",
"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])))",
"legendFormat": "{{db_operation}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineInterpolation": "smooth",
"fillOpacity": 20
},
"unit": "ops"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 60 },
"id": 15,
"title": "Redis Command Rate",
"type": "timeseries",
"targets": [
{
"expr": "sum by (db_operation) (rate(airi_db_client_operation_duration_milliseconds_count{service_name=~\"$service\", db_system=\"redis\"}[$__rate_interval]))",
"legendFormat": "{{db_operation}}"
}
]
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 68 },
"id": 104,
"title": "Application Logs",
"type": "row"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 69 },
"id": 16,
"title": "Application Logs",
"type": "logs",
"targets": [
{
"expr": "{service_name=~\"$service\"} |= ``",
"legendFormat": ""
}
],
"options": {
"showTime": true,
"showLabels": true,
"showCommonLabels": false,
"wrapLogMessage": true,
"prettifyLogMessage": false,
"enableLogDetails": true,
"sortOrder": "Descending"
}
}
],
"schemaVersion": 39,
"tags": ["airi", "observability"],
"templating": {
"list": [
{
"current": { "selected": false, "text": "Prometheus", "value": "Prometheus" },
"hide": 0,
"includeAll": false,
"name": "datasource",
"options": [],
"query": "prometheus",
"refresh": 1,
"type": "datasource"
},
{
"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)",
"hide": 0,
"includeAll": true,
"multi": true,
"name": "service",
"query": "label_values(airi_http_server_request_duration_milliseconds_count, service_name)",
"refresh": 2,
"type": "query"
}
]
},
"time": { "from": "now-1h", "to": "now" },
"title": "AIRI Server Overview",
"uid": "airi-server-overview"
}
@@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: AIRI Dashboards
orgId: 1
folder: AIRI
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,45 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
jsonData:
httpMethod: POST
exemplarTraceIdDestinations:
- name: traceID
datasourceUid: tempo
- name: Loki
type: loki
access: proxy
url: http://loki:3100
editable: true
jsonData:
derivedFields:
- datasourceUid: tempo
matcherRegex: '"traceId":"(\w+)"'
name: TraceID
url: '$${__value.raw}'
- name: Tempo
type: tempo
access: proxy
url: http://tempo:3200
uid: tempo
editable: true
jsonData:
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true
tracesToMetrics:
datasourceUid: prometheus
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true
lokiSearch:
datasourceUid: loki
+36
View File
@@ -0,0 +1,36 @@
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: '2024-01-01'
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
allow_structured_metadata: true
volume_enabled: true
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
@@ -0,0 +1,16 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
# Scrape OTEL Collector's Prometheus exporter
- job_name: otel-collector
static_configs:
- targets: ['otel-collector:8889']
labels:
service: otel-collector
# Scrape OTEL Collector's own metrics
- job_name: otel-collector-internal
static_configs:
- targets: ['otel-collector:8888']
+47
View File
@@ -0,0 +1,47 @@
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal
metrics_generator:
registry:
external_labels:
source: tempo
cluster: docker-compose
storage:
path: /var/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
send_exemplars: true
traces_storage:
path: /var/tempo/generator/traces
processor:
service_graphs:
dimensions:
- http.method
- http.target
span_metrics:
dimensions:
- http.method
- http.target
- http.status_code
overrides:
defaults:
metrics_generator:
processors: [service-graphs, span-metrics]
+17 -2
View File
@@ -12,10 +12,25 @@
"db:push": "pnpm run apply:env -- drizzle-kit push"
},
"dependencies": {
"@better-auth/drizzle-adapter": "^1.5.3",
"@dotenvx/dotenvx": "^1.52.0",
"@electric-sql/pglite": "catalog:",
"@guiiai/logg": "catalog:",
"@hono/node-server": "^1.19.9",
"@hono/node-server": "^1.19.11",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.213.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.213.0",
"@opentelemetry/instrumentation-http": "^0.213.0",
"@opentelemetry/instrumentation-ioredis": "^0.61.0",
"@opentelemetry/instrumentation-pg": "^0.65.0",
"@opentelemetry/instrumentation-runtime-node": "^0.12.2",
"@opentelemetry/resources": "^2.6.0",
"@opentelemetry/sdk-logs": "^0.213.0",
"@opentelemetry/sdk-metrics": "^2.6.0",
"@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/sdk-trace-node": "^2.6.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@proj-airi/drizzle-orm-browser-migrator": "^0.1.4",
"@proj-airi/server-schema": "workspace:*",
"better-auth": "^1.4.19",
@@ -28,7 +43,7 @@
"valibot": "catalog:"
},
"devDependencies": {
"@better-auth/cli": "^1.4.19",
"@better-auth/cli": "^1.4.21",
"drizzle-kit": "^0.31.9"
}
}
+29 -4
View File
@@ -8,12 +8,14 @@ import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { cors } from 'hono/cors'
import { logger as honoLogger } from 'hono/logger'
import { createLoggLogger, injeca } from 'injeca'
import { createLoggLogger, injeca, lifecycle } from 'injeca'
import { createAuth } from './libs/auth'
import { createDrizzle, migrateDatabase } from './libs/db'
import { parsedEnv } from './libs/env'
import { initOtel } from './libs/otel'
import { sessionMiddleware } from './middlewares/auth'
import { otelMiddleware } from './middlewares/otel'
import { createCharacterRoutes } from './routes/characters'
import { createChatRoutes } from './routes/chats'
import { createProviderRoutes } from './routes/providers'
@@ -28,17 +30,20 @@ type CharacterService = ReturnType<typeof createCharacterService>
type ChatService = ReturnType<typeof createChatService>
type ProviderService = ReturnType<typeof createProviderService>
type OtelMetrics = ReturnType<typeof initOtel>
interface AppDeps {
auth: AuthService
characterService: CharacterService
chatService: ChatService
providerService: ProviderService
otel: OtelMetrics | null
}
function buildApp({ auth, characterService, chatService, providerService }: AppDeps) {
function buildApp({ auth, characterService, chatService, providerService, otel }: AppDeps) {
const logger = useLogger('app').useGlobalConfig()
return new Hono<HonoEnv>()
const app = new Hono<HonoEnv>()
.use(
'/api/*',
cors({
@@ -47,6 +52,12 @@ function buildApp({ auth, characterService, chatService, providerService }: AppD
}),
)
.use(honoLogger())
if (otel) {
app.use('*', otelMiddleware(otel))
}
return app
.use('*', sessionMiddleware(auth))
.use('*', bodyLimit({ maxSize: 1024 * 1024 }))
.onError((err, c) => {
@@ -102,6 +113,19 @@ async function createApp() {
injeca.setLogger(createLoggLogger(useLogger('injeca').useGlobalConfig()))
const logger = useLogger('app').useGlobalConfig()
const otel = injeca.provide('otel', {
dependsOn: { env: parsedEnv, lifecycle },
build: ({ dependsOn }) => {
const o = initOtel(dependsOn.env)
if (!o)
return null
o.start()
dependsOn.lifecycle.appHooks.onStop(() => o.shutdown())
return o
},
})
const db = injeca.provide('services:db', {
dependsOn: { env: parsedEnv },
build: async ({ dependsOn }) => {
@@ -136,12 +160,13 @@ async function createApp() {
})
await injeca.start()
const resolved = await injeca.resolve({ auth, characterService, chatService, providerService })
const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, otel })
const app = buildApp({
auth: resolved.auth,
characterService: resolved.characterService,
chatService: resolved.chatService,
providerService: resolved.providerService,
otel: resolved.otel,
})
logger.withFields({ port: 3000 }).log('Server started')
+8
View File
@@ -15,6 +15,14 @@ const EnvSchema = object({
AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')),
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_SERVICE_NAME: optional(string(), 'server'),
OTEL_TRACES_SAMPLING_RATIO: optional(string(), '1.0'),
OTEL_EXPORTER_OTLP_ENDPOINT: optional(string()),
OTEL_EXPORTER_OTLP_HEADERS: optional(string()),
OTEL_DEBUG: optional(string()),
})
export type Env = InferOutput<typeof EnvSchema>
+167
View File
@@ -0,0 +1,167 @@
import type { IncomingMessage } from 'node:http'
import type { Env } from './env'
import { env as processEnv } from 'node:process'
import { useLogger } from '@guiiai/logg'
import { diag, DiagConsoleLogger, DiagLogLevel, metrics } from '@opentelemetry/api'
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'
import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'
import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
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'
const logger = useLogger('otel')
export function initOtel(env: Env) {
const otlpEndpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT
const serviceName = env.OTEL_SERVICE_NAME
if (!otlpEndpoint) {
logger.log('OpenTelemetry disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)')
return
}
if (env.OTEL_DEBUG === 'true') {
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG)
}
// Parse OTEL_EXPORTER_OTLP_HEADERS (format: "key=value,key2=value2")
const headers: Record<string, string> = {}
const rawHeaders = env.OTEL_EXPORTER_OTLP_HEADERS
if (rawHeaders) {
for (const pair of rawHeaders.split(',')) {
const idx = pair.indexOf('=')
if (idx > 0) {
headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
}
}
}
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_VERSION]: processEnv.npm_package_version || '0.0.0',
'service.namespace': env.OTEL_SERVICE_NAMESPACE,
'deployment.environment': processEnv.NODE_ENV || 'development',
})
const traceExporter = new OTLPTraceExporter({
url: `${otlpEndpoint}/v1/traces`,
headers,
})
const metricExporter = new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
headers,
})
const logExporter = new OTLPLogExporter({
url: `${otlpEndpoint}/v1/logs`,
headers,
})
// Head-based sampling ratio: 1.0 = 100% (default), 0.1 = 10%, etc.
// Metrics are always 100% accurate regardless of this setting.
const samplingRatio = Number.parseFloat(env.OTEL_TRACES_SAMPLING_RATIO)
const sampler = new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(samplingRatio),
})
const sdk = new NodeSDK({
resource,
sampler,
spanProcessors: [new BatchSpanProcessor(traceExporter)],
metricReader: new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: 15000,
}),
logRecordProcessors: [new BatchLogRecordProcessor(logExporter)],
instrumentations: [
new HttpInstrumentation({
ignoreIncomingRequestHook: (req: IncomingMessage) => {
// Ignore health check requests to reduce noise
return req.url === '/health'
},
}),
new PgInstrumentation({
enhancedDatabaseReporting: true,
}),
new IORedisInstrumentation(),
new RuntimeNodeInstrumentation(),
],
})
const start = () => {
sdk.start()
logger.log(`OpenTelemetry initialized, exporting to ${otlpEndpoint}, sampling ratio: ${samplingRatio}`)
}
const meter = metrics.getMeter(serviceName)
// Custom application metrics
const httpRequestDuration = meter.createHistogram('http.server.request.duration', {
description: 'HTTP server request duration in milliseconds',
unit: 'ms',
})
const httpActiveRequests = meter.createUpDownCounter('http.server.active_requests', {
description: 'Number of active HTTP requests',
})
const dbQueryDuration = meter.createHistogram('db.client.operation.duration', {
description: 'Database operation duration in milliseconds',
unit: 'ms',
})
const redisCommandDuration = meter.createHistogram('redis.client.command.duration', {
description: 'Redis command duration in milliseconds',
unit: 'ms',
})
const authAttempts = meter.createCounter('auth.attempts', {
description: 'Number of authentication attempts',
})
const authFailures = meter.createCounter('auth.failures', {
description: 'Number of failed authentication attempts',
})
const stripeEvents = meter.createCounter('stripe.events', {
description: 'Number of Stripe webhook events processed',
})
// Graceful shutdown
const shutdown = async () => {
try {
await sdk.shutdown()
logger.log('OpenTelemetry shut down successfully')
}
catch (err) {
logger.withError(err).error('Error shutting down OpenTelemetry')
}
}
return {
sdk,
meter,
httpRequestDuration,
httpActiveRequests,
dbQueryDuration,
redisCommandDuration,
authAttempts,
authFailures,
stripeEvents,
start,
shutdown,
}
}
+57
View File
@@ -0,0 +1,57 @@
import type { MiddlewareHandler } from 'hono'
import type { HonoEnv } from '../types/hono'
import { context, SpanStatusCode, trace } from '@opentelemetry/api'
const tracer = trace.getTracer('airi-server-hono')
/**
* Hono middleware that creates spans for each request and records
* custom HTTP metrics (duration, active requests, status codes).
*/
export function otelMiddleware(otelMetrics: {
httpRequestDuration: { record: (value: number, attributes?: Record<string, string | number>) => void }
httpActiveRequests: { add: (value: number, attributes?: Record<string, string>) => void }
}): MiddlewareHandler<HonoEnv> {
return async (c, next) => {
const startTime = performance.now()
const method = c.req.method
const path = c.req.routePath || c.req.path
const attributes = {
'http.method': method,
'http.route': path,
'http.url': c.req.url,
}
otelMetrics.httpActiveRequests.add(1, { 'http.method': method, 'http.route': path })
const span = tracer.startSpan(`${method} ${path}`, { attributes })
try {
await context.with(trace.setSpan(context.active(), span), () => next())
const status = c.res.status
span.setAttribute('http.status_code', status)
if (status >= 500) {
span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${status}` })
}
otelMetrics.httpRequestDuration.record(performance.now() - startTime, {
'http.method': method,
'http.route': path,
'http.status_code': status,
})
}
catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: err instanceof Error ? err.message : 'Unknown error' })
span.recordException(err instanceof Error ? err : new Error(String(err)))
throw err
}
finally {
otelMetrics.httpActiveRequests.add(-1, { 'http.method': method, 'http.route': path })
span.end()
}
}
}
+934 -125
View File
File diff suppressed because it is too large Load Diff