Skip to content

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

  1. OpenTelemetry als Standard — Vendor-neutral, austauschbares Backend (Jaeger, Tempo, Honeycomb, Datadog, Prometheus, Cloud-Native).
  2. 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.
  3. Provider-Interface fuer Backend — Default ist OTLP (Standard-Protokoll), Implementierungen als separate Pakete.
  4. Sensitive-Data-Filter ist Pflicht — kein Request-Body, keine Auth-Header, keine Secret<>-Werte je in Spans/Metrics/Logs.
  5. Sampling intelligent — Default 10% Tracing, aber Errors und Slow-Requests immer 100%.

Drei Saeulen

SaeuleOTel-APIDefault-BackendOptionale Backends
Logs@opentelemetry/api-logs (pino-Bridge)Konsole + OTLPLoki, Elasticsearch, Cloud-Logs
Metrics@opentelemetry/api Metrics-APIOTLPPrometheus (Scrape), Cortex, Cloud-Metrics
Tracing@opentelemetry/api Trace-APIOTLPJaeger, 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

kumiko.config.ts
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.

SpanWo entstehtStandard-Attribute
http.requestHono-Middlewarehttp.method, http.route, http.status_code, kumiko.user_id, kumiko.tenant_id, kumiko.request_id
auth.verifyAuth-Middlewareauth.source (jwt/pat), auth.user_id
dispatcher.handlerDispatcherkumiko.handler, kumiko.feature, kumiko.access_check
pipeline.hookLifecycle-Pipelinehook.type, hook.feature, hook.phase, hook.entity
db.queryDB-Wrapperdb.system, db.operation, db.table, db.row_count
db.transactionTX-Wrapperdb.tx.duration_ms, db.tx.outcome (commit/rollback)
outbox.publishOutbox-Polleroutbox.event_type, outbox.attempt, outbox.outcome
redis.cmdRedis-Client-Wrapperredis.command, redis.key_pattern (kein raw key — PII-Risk)
external.httpoptional via OTel-Auto-Instrumentationhttp.host, http.method, http.status
job.executeJobs-Featurejob.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.

MetricTypLabels
kumiko_http_requests_totalcounterroute, method, status
kumiko_http_request_duration_secondshistogramroute, method
kumiko_dispatcher_handler_duration_secondshistogramhandler, success
kumiko_dispatcher_handler_errors_totalcounterhandler, error_class
kumiko_db_query_duration_secondshistogramoperation, table
kumiko_db_pool_active_connectionsgaugepool
kumiko_db_transaction_duration_secondshistogramoutcome
kumiko_outbox_depthgauge-
kumiko_outbox_published_totalcounterevent_type
kumiko_outbox_dead_letter_totalcounterevent_type
kumiko_outbox_retry_totalcounterevent_type
kumiko_redis_command_duration_secondshistogramcommand
kumiko_job_duration_secondshistogramjob_name, outcome
kumiko_feature_handler_call_totalcounterfeature, handler
kumiko_tenant_countgauge-
kumiko_session_active_countgauge-
kumiko_rate_limit_rejected_totalcounterdimension, 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

SituationSampling
Default10% Tracing
Error (HTTP 5xx, exception)100% (always-on)
Slow (http.request > 1000ms)100% (tail-sampling, falls Provider unterstuetzt)
Health-Check0% (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:

QuelleFilter
HTTP-Request-BodyNie als Span-Attribute oder Log-Field. Nur http.body_size als Zahl.
Header authorization, cookie, x-api-keyWerden durch [REDACTED] ersetzt
Query-Param-Werte (Token, Secret-Pattern)[REDACTED]
Secret<>-Brand-TypeSpan-Attribute-Setter prueft mit Type-Guard, lehnt ab mit Error
Redis-KeysNur Pattern (z.B. session:*), nie der ganze Key
DB-Query-ParameterParametrisiert, 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

AusbauWarum
ObservabilityProvider-Interface im Framework-KernAustauschbar
NoopProvider als DefaultKein Crash bei fehlender Config
AsyncLocalStorage Context-PropagationTrace-Context durch async Code
Auto-Span-Wrapping in Hono-Middleware, Dispatcher, Lifecycle, DB, Outbox, RedisAuto-Instrumentierung
Standard-Metrics-Initialisierung im Framework-BootOut-of-the-box-Sicht
r.metric(name, options) Registrar-MethodeCustom Business-Metrics
ctx.metrics Context-AccessorMetric-Update aus Handler
Pino-Hook fuer TraceID/SpanID-InjectionLogs ↔ Tracing-Bridge
Sensitive-Filter im Span-Attribute-SetterCompliance
/health/ready EndpointOps-Sichtbarkeit
Sampling-Konfig im kumiko.config.tsOperator-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: true kein 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/ready mit gestopptem Redis: 503
  • /health/ready mit 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

  1. ObservabilityProvider-Interface + NoopProvider
  2. AsyncLocalStorage Context-Propagation
  3. Auto-Span-Wrapping in Hono-Middleware (http.request)
  4. Auto-Span-Wrapping in Dispatcher + Lifecycle + DB + Redis
  5. Standard-Metrics-Set im Framework-Boot
  6. r.metric() + ctx.metrics
  7. Sensitive-Filter im Span-Setter
  8. Pino-TraceID-Bridge
  9. Sampling-Strategie + Always-on bei Error/Slow
  10. /health/ready-Endpoint
  11. @kumiko/observability-console-Paket (fuer Dev)
  12. @kumiko/observability-otlp-Paket
  13. @kumiko/observability-prometheus-Paket (Scrape-Endpoint)
  14. Cross-Process-Trace-Propagation (Outbox-Row, Job-Payload)
  15. Integration-Tests
  16. Post-Todos:
    • Frontend-OTel-Bridge
    • SLO-Templates fuer Standard-Metrics (als Doku/Snippets fuer Operator)