Observability (Framework-Infrastruktur)
Logs, Metrics, Tracing nach OpenTelemetry-Standard. Auto-Instrumentierung durch das Framework — Feature-Autoren bekommen Standard-Spans/Metrics gratis, eigene Business-Metrics via API.
Kein Feature, sondern Framework-Infra wie Outbox. Wirkt auf jeden Request, ohne dass Features opt-in sagen muessen.
Prinzipien
- OpenTelemetry als Standard — Vendor-neutral, austauschbares Backend (Jaeger, Tempo, Honeycomb, Datadog, Prometheus, Cloud-Native).
- Auto-Instrumentierung im Framework — wenn Feature-Autoren Spans manuell setzen muessten, wuerde es niemand tun. Framework instrumentiert HTTP, Dispatcher, Pipeline, Hooks, DB, Outbox, Redis, External-HTTP automatisch.
- Provider-Interface fuer Backend — Default ist OTLP (Standard-Protokoll), Implementierungen als separate Pakete.
- Sensitive-Data-Filter ist Pflicht — kein Request-Body, keine Auth-Header, keine
Secret<>-Werte je in Spans/Metrics/Logs. - Sampling intelligent — Default 10% Tracing, aber Errors und Slow-Requests immer 100%.
Drei Saeulen
| Saeule | OTel-API | Default-Backend | Optionale Backends |
|---|---|---|---|
| Logs | @opentelemetry/api-logs (pino-Bridge) | Konsole + OTLP | Loki, Elasticsearch, Cloud-Logs |
| Metrics | @opentelemetry/api Metrics-API | OTLP | Prometheus (Scrape), Cortex, Cloud-Metrics |
| Tracing | @opentelemetry/api Trace-API | OTLP | Jaeger, Tempo, Honeycomb, Datadog |
Alle drei teilen sich denselben Trace-Context (TraceID, SpanID) — Logs sind Spans zugeordnet, Metrics tragen Exemplars die zu Spans linken.
Provider-Interface
interface ObservabilityProvider { initTracing(config): TracerProvider; initMetrics(config): MeterProvider; initLogs(config): LoggerProvider; shutdown(): Promise<void>;}Implementierungen als separate Pakete
@kumiko/framework → enthaelt Interface + NoopProvider (Default in Tests)@kumiko/observability-otlp → OTLP-Standard (Default fuer Prod)@kumiko/observability-prometheus → Prometheus-Scrape-Endpoint (klassisches Setup)@kumiko/observability-console → Konsole (Dev)Konfiguration
export default { observability: { provider: "otlp", // "noop" | "otlp" | "prometheus" | "console" otlp: { endpoint: "http://collector:4317", headers: { ... } },
sampling: { tracing: 0.1, // 10% default alwaysOnError: true, alwaysOnSlow: { thresholdMs: 1000 }, },
sensitiveFilter: { headers: ["authorization", "cookie", "x-api-key"], queryParams: ["token", "secret"], }, },};Ein Provider pro Deployment.
Auto-Instrumentierung (Framework-Seite)
Alle Spans hierarchisch — ein HTTP-Request hat Child-Spans fuer alles was er ausloest.
| Span | Wo entsteht | Standard-Attribute |
|---|---|---|
http.request | Hono-Middleware | http.method, http.route, http.status_code, kumiko.user_id, kumiko.tenant_id, kumiko.request_id |
auth.verify | Auth-Middleware | auth.source (jwt/pat), auth.user_id |
dispatcher.handler | Dispatcher | kumiko.handler, kumiko.feature, kumiko.access_check |
pipeline.hook | Lifecycle-Pipeline | hook.type, hook.feature, hook.phase, hook.entity |
db.query | DB-Wrapper | db.system, db.operation, db.table, db.row_count |
db.transaction | TX-Wrapper | db.tx.duration_ms, db.tx.outcome (commit/rollback) |
outbox.publish | Outbox-Poller | outbox.event_type, outbox.attempt, outbox.outcome |
redis.cmd | Redis-Client-Wrapper | redis.command, redis.key_pattern (kein raw key — PII-Risk) |
external.http | optional via OTel-Auto-Instrumentation | http.host, http.method, http.status |
job.execute | Jobs-Feature | job.name, job.attempt, job.outcome |
Spans sind hierarchisch verschachtelt: http.request ist Parent von auth.verify, dispatcher.handler ist Parent von mehreren pipeline.hook und db.query.
Standard-Metrics
Werden alle automatisch emittiert, ohne dass Feature-Code etwas tun muss.
| Metric | Typ | Labels |
|---|---|---|
kumiko_http_requests_total | counter | route, method, status |
kumiko_http_request_duration_seconds | histogram | route, method |
kumiko_dispatcher_handler_duration_seconds | histogram | handler, success |
kumiko_dispatcher_handler_errors_total | counter | handler, error_class |
kumiko_db_query_duration_seconds | histogram | operation, table |
kumiko_db_pool_active_connections | gauge | pool |
kumiko_db_transaction_duration_seconds | histogram | outcome |
kumiko_outbox_depth | gauge | - |
kumiko_outbox_published_total | counter | event_type |
kumiko_outbox_dead_letter_total | counter | event_type |
kumiko_outbox_retry_total | counter | event_type |
kumiko_redis_command_duration_seconds | histogram | command |
kumiko_job_duration_seconds | histogram | job_name, outcome |
kumiko_feature_handler_call_total | counter | feature, handler |
kumiko_tenant_count | gauge | - |
kumiko_session_active_count | gauge | - |
kumiko_rate_limit_rejected_total | counter | dimension, handler |
Custom Business-Metrics (Feature-API)
Features deklarieren eigene Metrics:
defineFeature("orders", (r) => { const orderCreated = r.metric("orders_created_total", { type: "counter", description: "Number of orders created", labels: ["status", "channel"], });
const orderValueHistogram = r.metric("orders_value_eur", { type: "histogram", description: "Order value in EUR", buckets: [10, 50, 100, 500, 1000, 5000, 10000], });
r.writeHandler("order.create", schema, async (event, ctx) => { const order = await ctx.db.insert(...); orderCreated.inc({ status: order.status, channel: event.channel }); orderValueHistogram.observe(order.totalEur); });});Metric-Names werden mit Feature-Prefix versehen: kumiko_orders_orders_created_total. Verhindert Kollision zwischen Features.
Tenant-Label automatisch
Optional pro Metric: tenantLabel: true → Framework injiziert tenant_id-Label aus ctx. Vorsicht bei Cardinality (10000 Tenants × 5 Labels = explodiert). Default ist aus.
Tracing-Context durch async Code
Trace-Context (TraceID, SpanID, Baggage) reist durch AsyncLocalStorage (Node) bzw. das Bun-Aequivalent. Damit ist es in jedem async Code verfuegbar — auch in Hooks, Job-Workern, Outbox-Pollern, ohne dass das Framework explizit weitergeben muss.
Cross-Process (Worker, Outbox-Poller) wird Context als Header/Metadata in Redis-Streams/Outbox-Rows mitgegeben. Job-Run zeigt im Trace die Verbindung zum auslosenden Request.
Logs ↔ Tracing-Bridge
Pino-Logger bekommt im Constructor einen Hook der traceId und spanId als Felder injiziert (aus AsyncLocalStorage). Damit:
// Log-Eintrag automatisch{ "msg": "...", "traceId": "abc...", "spanId": "def...", "userId": "...", "tenantId": "..." }Im Backend (Loki/Elasticsearch) kann man von Log direkt zu Trace springen.
Sampling-Strategie
| Situation | Sampling |
|---|---|
| Default | 10% Tracing |
| Error (HTTP 5xx, exception) | 100% (always-on) |
Slow (http.request > 1000ms) | 100% (tail-sampling, falls Provider unterstuetzt) |
| Health-Check | 0% (sonst spammt es alles voll) |
Specific Headers (X-Trace: 1) | 100% (Dev/Debug) |
Sampling-Rate per Config aenderbar pro Span-Type wenn noetig.
Sensitive-Data-Filter (hart)
Diese Filter sind immer aktiv, nicht abschaltbar:
| Quelle | Filter |
|---|---|
| HTTP-Request-Body | Nie als Span-Attribute oder Log-Field. Nur http.body_size als Zahl. |
Header authorization, cookie, x-api-key | Werden durch [REDACTED] ersetzt |
| Query-Param-Werte (Token, Secret-Pattern) | [REDACTED] |
Secret<>-Brand-Type | Span-Attribute-Setter prueft mit Type-Guard, lehnt ab mit Error |
| Redis-Keys | Nur Pattern (z.B. session:*), nie der ganze Key |
| DB-Query-Parameter | Parametrisiert, nie raw-Values im Span |
Bei Verstoss: Span wird nicht emittiert, Log-Alarm. Failsafe.
Health-Check-Erweiterung
/health bleibt simpel (200 OK wenn Server lebt — fuer Load-Balancer).
/health/ready neu: prueft Abhaengigkeiten:
- DB Reachable + < 100ms
- Redis Reachable
- Observability-Provider initialisiert
- Outbox-Poller laeuft (heartbeat letzten 30s)
Antwort:
{ "status": "ready", "checks": { "db": { "ok": true, "latencyMs": 12 }, "redis": { "ok": true, "latencyMs": 1 }, "observability": { "ok": true, "provider": "otlp" }, "outboxPoller": { "ok": true, "lastTickAgoSec": 5 }, }}503 wenn was nicht ok ist.
Framework-Ausbau
| Ausbau | Warum |
|---|---|
ObservabilityProvider-Interface im Framework-Kern | Austauschbar |
NoopProvider als Default | Kein Crash bei fehlender Config |
| AsyncLocalStorage Context-Propagation | Trace-Context durch async Code |
| Auto-Span-Wrapping in Hono-Middleware, Dispatcher, Lifecycle, DB, Outbox, Redis | Auto-Instrumentierung |
| Standard-Metrics-Initialisierung im Framework-Boot | Out-of-the-box-Sicht |
r.metric(name, options) Registrar-Methode | Custom Business-Metrics |
ctx.metrics Context-Accessor | Metric-Update aus Handler |
| Pino-Hook fuer TraceID/SpanID-Injection | Logs ↔ Tracing-Bridge |
| Sensitive-Filter im Span-Attribute-Setter | Compliance |
/health/ready Endpoint | Ops-Sichtbarkeit |
Sampling-Konfig im kumiko.config.ts | Operator-Steuerung |
Dev-Erfahrung
- Default
provider: "noop"→ keine Logs/Spans, keine Performance-Kosten - Optional
provider: "console"→ Spans als pretty-printed Konsole-Ausgabe (sehr hilfreich beim Debuggen, “warum dauert dieser Handler so lang?”) - Prod:
provider: "otlp"mit Collector-Endpoint
Was NICHT im Scope ist
- APM/Profiling (Heap, CPU-Profiles): andere Werkzeuge (Node-Inspector, Bun-Profiler), nicht Observability
- Frontend-Tracing: braucht eigenen Browser-OTel-Setup, Post-Todo
- SLO/SLA-Berechnung: aufgesetztes Backend-Tooling (Sloth, Pyrra), nicht Framework
- Anomalie-Erkennung: Backend-Aufgabe (Grafana, Datadog), nicht Framework
- Custom-Trace-Backends jenseits OTel (Sentry direkt, etc.): OTel-Bridges existieren als externe Pakete
Tests — der Beweis
Unit
- Span-Hierarchie: HTTP-Request hat Dispatcher-Span als Child, der wiederum DB-Spans
- Sampling: 100 Requests mit 10% Rate ergeben ~10 Spans, 100% bei Errors
- Sensitive-Filter: Span-Attribute-Setter mit
Secret<>-Wert wirft Error - Sensitive-Filter: Authorization-Header in Span landet als
[REDACTED] - Pino-Bridge: Log-Eintrag haelt traceId/spanId der aktuellen Span
- Custom Metric:
r.metric().inc()erscheint im Provider-Output - Tenant-Label-Cardinality: ohne
tenantLabel: truekein tenant_id-Label
Integration (full-stack)
- Provider
console: Request macht sichtbare Span-Tree-Ausgabe - Provider
otlp: spans werden an Mock-Collector geschickt - Tenant-Cross-Trace: Job ausgeloest von Request hat selbe traceId
- Outbox-Publish: Span ist Child des emittierenden Handler-Spans (cross-process via Outbox-Row-Metadata)
/health/readymit gestopptem Redis: 503/health/readymit gesundem System: 200, alle checks ok- Kein PII-Leak: Request mit Body inkl. PII-Feldern → Span enthaelt keinen Body
Sample
Sample mit aktivem console-Provider zeigt im Terminal die volle Span-Tree fuer einen End-to-End-Order-Create-Request.
Build-Reihenfolge
ObservabilityProvider-Interface +NoopProvider- AsyncLocalStorage Context-Propagation
- Auto-Span-Wrapping in Hono-Middleware (
http.request) - Auto-Span-Wrapping in Dispatcher + Lifecycle + DB + Redis
- Standard-Metrics-Set im Framework-Boot
r.metric()+ctx.metrics- Sensitive-Filter im Span-Setter
- Pino-TraceID-Bridge
- Sampling-Strategie + Always-on bei Error/Slow
/health/ready-Endpoint@kumiko/observability-console-Paket (fuer Dev)@kumiko/observability-otlp-Paket@kumiko/observability-prometheus-Paket (Scrape-Endpoint)- Cross-Process-Trace-Propagation (Outbox-Row, Job-Payload)
- Integration-Tests
- Post-Todos:
- Frontend-OTel-Bridge
- SLO-Templates fuer Standard-Metrics (als Doku/Snippets fuer Operator)