Infrastruktur: Logging, Error Handling, Request Context, Shutdown
Querschnitt-Themen die im Framework-Core stecken — keine Features, sondern automatisch da.
Logging
Logger Interface (Server + Client gleich)
type Logger = { info(msg: string, data?: Record<string, unknown>): void; warn(msg: string, data?: Record<string, unknown>): void; error(msg: string, data?: Record<string, unknown>): void; debug(msg: string, data?: Record<string, unknown>): void; child(context: Record<string, unknown>): Logger;};Der Entwickler schreibt ueberall ctx.log.info("message") — egal ob Server oder Client.
Implementierungen
| Umgebung | Implementierung | Transport |
|---|---|---|
| Server (Bun) | pino (schnellster JSON Logger) | stdout → Logfile/Cloud |
| Client (Expo) | Leichter Wrapper, gleiche API | Console (Dev), POST /log an Server (Prod) |
Automatischer Context
Framework erstellt pro Request/Job einen Child Logger mit Context:
// In der Pipeline — automatisch, kein Code vom Entwickler:const requestLogger = log.child({ requestId: "req-abc-123", tenantId: 42, userId: 7, handler: "orders.order.create",});
// Im Handler — der Entwickler schreibt nur:ctx.log.info("Order created", { orderId: 123 });
// Ausgabe (JSON):// {"level":"info","requestId":"req-abc-123","tenantId":42,"userId":7,// "handler":"orders.order.create","msg":"Order created","orderId":123}Correlation: Request → Job → Event
Request-ID propagiert automatisch durch die gesamte Kette:
Request req-abc-123 → WriteHandler (ctx.log hat requestId) → SharedEvent (requestId im Payload) → Job (ctx.log hat origRequestId) → Delivery (ctx.log hat origRequestId)Alle Logs zu einem User-Klick sind ueber die Request-ID filterbar.
Client-Logs in Production
Client schickt Logs an POST /log — Framework sammelt und loggt server-seitig:
// Client (automatisch im Logger-Wrapper):await fetch("/log", { method: "POST", body: JSON.stringify({ level: "error", msg: "Failed to load order", screen: "orderDetail", orderId: 123, }),});// → Server loggt mit tenantId + userId aus JWTDev vs. Production
| Umgebung | Format | Level |
|---|---|---|
| Dev | Huebsch (pino-pretty) | debug |
| Production | JSON (structured) | info |
Konfigurierbar via ENV: LOG_LEVEL=debug, LOG_FORMAT=pretty.
Error Handling
Error-Klassen
Framework liefert typisierte Error-Klassen die automatisch auf HTTP Status gemappt werden:
// Im Handler — der Entwickler wirft nur:throw new NotFoundError("order", orderId); // → 404throw new AccessDeniedError("order.create"); // → 403throw new ValidationError([{ field: "email", error: "invalid" }]); // → 422throw new ConflictError("version_conflict"); // → 409throw new PipelineError("custom message", 400); // → beliebiger StatusAutomatische Error-Response
Hono Middleware faengt alle Errors und liefert einheitliche Responses:
// Client bekommt immer:{ "error": "not_found", "message": "order 123 not found", "requestId": "req-abc-123"}
// Bei ValidationError zusaetzlich:{ "error": "validation_error", "message": "Validation failed", "requestId": "req-abc-123", "fields": [ { "field": "email", "error": "invalid" } ]}Automatisches Logging
Jeder Error wird automatisch geloggt — der Entwickler muss kein try/catch mit Logging bauen:
| Error-Typ | Log-Level | Stack Trace |
|---|---|---|
| ValidationError (422) | warn | Nein |
| NotFoundError (404) | warn | Nein |
| AccessDeniedError (403) | warn | Nein |
| ConflictError (409) | warn | Nein |
| Unbekannter Error (500) | error | Ja (nur Server-Log, nie an Client) |
Dev vs. Production
| Umgebung | Error-Response |
|---|---|
| Dev | Error + Message + Stack Trace |
| Production | Error + Message + RequestId (kein Stack Trace) |
Request Context
Zwei Mechanismen
| Mechanismus | Wo | Wofuer |
|---|---|---|
| AsyncLocalStorage | Ueberall (auch ohne ctx) | Logger, Request-ID |
| PipelineContext (ctx) | Handler, Hooks, Jobs | Alles explizite |
PipelineContext (erweitert)
type PipelineContext = { // Schon da: readonly db: DrizzleInstance; readonly user: PipelineUser;
// Neu: readonly log: Logger; // Child Logger mit Request Context readonly requestId: string; // Correlation-ID readonly config: ConfigAccessor; // ctx.config("feature.key") readonly delivery: DeliveryAccessor; // ctx.delivery.send(...)};Request-ID Generierung
// Hono Middleware:app.use("*", async (c, next) => { // Aus Header uebernehmen (Proxy/Load Balancer) oder generieren const requestId = c.req.header("X-Request-ID") ?? generateRequestId(); c.header("X-Request-ID", requestId);
// AsyncLocalStorage setzen await requestContext.run({ requestId, tenantId, userId }, next);});Request Cancellation (ctx.signal)
Mobile- und Web-Clients brechen Requests häufig ab — User navigiert weg, Tab wird zu, Mobile-App geht in den Hintergrund. Ohne Propagation läuft der Server-Side-Handler bis zum Ende und produziert Bytes / DB-Last die niemand mehr lesen wird. ctx.signal macht den Browser-Abort sichtbar im Handler.
Wie es fließt
- HTTP-Layer: Hono exponiert
c.req.raw.signal(Fetch-StandardAbortSignal).requestIdMiddlewareextrahiert es und legt es in denrequestContext(AsyncLocalStorage). - Dispatcher: Beim Bauen der
HandlerContextzieht der DispatchersignalausrequestContext.get()und setztctx.signal— nur wenn vorhanden (kein Phantom-Signal für Background-Jobs / MSP-Applies). - Handler: Liest
ctx.signal?.throwIfAborted()an Stellen wo es sich lohnt (Loops, Multi-Step-Workflows). Kurze Handler ignorieren es einfach — kein Boilerplate-Zwang.
Wann nutzen
r.queryHandler("export:big-csv", schema, async (q, ctx) => { for (const row of bigDataset) { ctx.signal?.throwIfAborted(); // ← pro-Iteration check await processRow(row); }});Faustregel: Wenn dein Handler in irgendeiner Schleife > 100ms verbringt, einen Check rein. Sonst: ignorieren — der Pipeline-Layer (DB-Wrapper, streamAllEventsByType, rebuildProjection) honoriert ctx.signal automatisch wo es Sinn macht.
Was NICHT enthalten ist (Stand Sprint G.3)
- Aktiver Postgres-Query-Cancel: Eine bereits laufende DB-Query wird nicht abgebrochen.
postgres-jsconnection-cancel ist riskant für den Connection-Pool — eigener Sprint wenn 30s+-Queries auftauchen. - Job-Runner / MSP-Apply Cancellation: Background-Pfade haben anderes Failure-Modell (Retry, Dead-Letter), brauchen kein HTTP-style cancel.
- Per-Query Pre-Flight: Stufe 1 wird in einem Folge-Sprint nachgezogen — TenantDb checkt
signal?.throwIfAborted()vor jedemawaitder nächsten Query, sodass Handler mit 10 sequentiellen Queries nach Abort nur die erste auslaufen lassen.
Graceful Shutdown
Ausfuehrlich in lifecycle.md. Kurz: SIGTERM → draining-State → /health/ready 503 → LB zieht Traffic → In-Flight-Requests drainiert → Background-Worker gestoppt → DB/Redis geschlossen → Observability-Flush → Exit. Default-Timeouts: 3s Linger + 30s Drain + Rest, gesamt 40s.
Framework handled automatisch, Feature-Code muss nichts tun.
Idempotency + Event Dedup
Ein Pattern, zwei Stellen: Writes und Events. Beide nutzen Redis SETNX mit TTL.
Writes (Client → Server)
User klickt doppelt, Client retried bei Timeout → requestId verhindert doppelte Verarbeitung.
Write kommt rein: requestId = "req-xyz-789" → Redis: SETNX "dedup:req-xyz-789" "1" EX 300 → Key neu → verarbeiten → Key existiert → skip, cached Result zurueckgebenHaben wir schon gebaut.
Events (Server → Handler/Job)
Redis Pub/Sub Reconnect, Worker Crash + Restart → eventId verhindert doppelte Verarbeitung.
Event kommt rein: eventId = "evt-abc-123" → Redis: SETNX "dedup:evt-abc-123" "1" EX 300 → Key neu → verarbeiten → Key existiert → skipAutomatische IDs
Framework generiert automatisch:
// SharedEvent bekommt eventId:await broker.emit({ kind: "shared", type: "order.stateChanged", eventId: "evt-abc-123", // Framework generiert automatisch payload: { ... }, causedBy: "req-xyz-789", // Request-ID die das Event ausgeloest hat});
// Job bekommt jobId:// BullMQ nutzt eigene Job-IDs — bei gleichem Job-Namen + Payload → DedupCorrelation Chain
requestId: "req-xyz-789" (User klickt) → eventId: "evt-abc-123" (SharedEvent, causedBy: req-xyz-789) → jobId: "job-def-456" (Job, causedBy: evt-abc-123) → eventId: "evt-ghi-789" (Neues Event aus Job, causedBy: job-def-456)Alle IDs sind im Log — kompletter Trace von User-Klick bis zum letzten Side-Effect.
SETNX ist atomar
Auch bei 10 Worker-Instanzen gleichzeitig: genau einer verarbeitet das Event. Redis garantiert das.
Was der Entwickler tut vs. was das Framework tut
| Thema | Entwickler | Framework |
|---|---|---|
| Logging | ctx.log.info("message") | Context (requestId, tenant, user), JSON Format, Transport |
| Errors | throw new NotFoundError(...) | Fangen, HTTP Status mappen, Response formatieren, Loggen |
| Request Context | Nichts | Request-ID generieren, propagieren, in ctx bereitstellen |
| Idempotency | requestId im Write mitgeben | Dedup via Redis SETNX, eventId automatisch |
| Shutdown | Nichts | Signals fangen, Connections schliessen, Jobs pausieren |