Strukturiertes Logging: Warum printf-Debugging nicht skaliert
console.log('user logged in', userId) funktioniert gut, bis man zehn Server hat, tausend Logzeilen pro Sekunde und einen Vorfall, bei dem man jede Anfrage eines Kunden über drei Dienste hinweg in den letzten zwanzig Minuten finden muss. Ab diesem Punkt sind Klartext-Logs kein Debugging-Werkzeug mehr, sondern ein Heuhaufen. Strukturiertes Logging ist die Lösung, und die Einführung kostet fast nichts.
Das Problem mit String-Logs
Eine klassische Logzeile ist ein für Menschen lesbarer Satz:
2026-03-17T09:14:02Z User 4821 logged in from 203.0.113.4 after 2 failed attempts
Isoliert leicht zu lesen. Im großen Maßstab fast unmöglich abzufragen. Um jeden Login von einer bestimmten IP über eine Woche Logs zu finden, schreibt man reguläre Ausdrücke gegen Freitext, hofft, dass nie jemand die Formulierung dieser Logzeile ändert (was jede darauf gebaute gespeicherte Abfrage kaputt macht), und hofft, dass die benötigten Felder – Nutzer-ID, IP, Anzahl Versuche – überhaupt an einer parsbaren Stelle enthalten waren.
Strukturiertes Logging: Daten loggen, keine Sätze
Eine strukturierte Logzeile ist ein maschinenlesbarer Datensatz, fast immer JSON, in dem jedes Stück Kontext ein eigenes Feld ist:
logger.info('user_login', {
userId: 4821,
ip: '203.0.113.4',
failedAttempts: 2,
});
{"level":"info","event":"user_login","userId":4821,"ip":"203.0.113.4","failedAttempts":2,"timestamp":"2026-03-17T09:14:02.118Z"}
Jetzt ist „jeder Login von dieser IP" eine Abfrage auf das Feld ip, kein regulärer Ausdruck gegen Prosa. „Jedes Ereignis für Nutzer 4821 in der letzten Stunde" ist ein Filter, kein grep. Der Ereignisname (user_login) bleibt stabil, auch wenn man später die menschenlesbare Formulierung ändern möchte – weil zur Schreibzeit gar keine menschenlesbare Version erzeugt wird; Formatierung ist eine Darstellungsfrage, die später von dem erledigt wird, was die Logs liest.
Ein minimaler strukturierter Logger
Man braucht kein schwergewichtiges Framework, um den Kernnutzen zu bekommen. Hier ist ein Logger, der konsistente Basisfelder anhängt und immer JSON ausgibt:
function createLogger(baseFields = {}) {
const emit = (level, event, fields = {}) => {
const record = {
level,
event,
timestamp: new Date().toISOString(),
...baseFields,
...fields,
};
process.stdout.write(JSON.stringify(record) + '\n');
};
return {
info: (event, fields) => emit('info', event, fields),
warn: (event, fields) => emit('warn', event, fields),
error: (event, fields) => emit('error', event, fields),
child: (extraFields) => createLogger({ ...baseFields, ...extraFields }),
};
}
const logger = createLogger({ service: 'auth-api' });
logger.info('user_login', { userId: 4821, ip: '203.0.113.4' });
child() ist wichtiger, als es aussieht: Es erlaubt, Kontext einmal anzuhängen und durch jeden folgenden Aufruf fließen zu lassen, statt Felder überall zu wiederholen.
Kontext pro Anfrage: der eigentliche Gewinn
Der echte Gewinn zeigt sich, wenn jede Logzeile einer einzelnen Anfrage automatisch eine gemeinsame Request-ID trägt, ohne dass jede Aufrufstelle sie ausdrücklich übergeben muss.
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage();
function withRequestContext(requestId, fn) {
return requestContext.run({ requestId }, fn);
}
function requestLogger(event, fields = {}) {
const context = requestContext.getStore() ?? {};
logger.info(event, { ...context, ...fields });
}
// In middleware, per incoming request:
app.use((req, res, next) => {
withRequestContext(crypto.randomUUID(), next);
});
// Anywhere deeper in the call stack, no requestId parameter needed:
requestLogger('db_query_start', { table: 'orders' });
requestLogger('db_query_end', { table: 'orders', durationMs: 12 });
Jede Logzeile, die irgendwo während der Lebensdauer dieser Anfrage ausgegeben wird – drei Dienste tief, in einem Callback, in einem Error-Handler –, trägt dieselbe requestId. Das macht aus „finde alles, was während dieser einen fehlgeschlagenen Anfrage passiert ist" ein Archäologieprojekt weniger und einen einzigen Filter mehr.
Was tatsächlich in ein Logfeld gehört
- IDs, keine Beschreibungen.
userId: 4821, nicht"für Nutzer Nummer 4821". Der Leser kann gegen die Nutzertabelle joinen, wenn er den Namen braucht. - Dauern als Zahlen, in einer einheitlichen Einheit.
durationMs: 340, immer Millisekunden, nie"340ms"und2.1(Sekunden) an verschiedenen Aufrufstellen mischen. - Stabile Ereignisnamen.
user_login, nicht ein Satz, dessen Formulierung zwischen Commits wechselt. Behandle den Ereignisnamen wie eine API – ihn umzubenennen bricht jedes darauf gebaute Dashboard und jeden Alarm. - Fehler als strukturierte Objekte, nicht nur ihr
.message:
logger.error('payment_failed', {
orderId: order.id,
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
});
Was es nicht ersetzt
Strukturiertes Logging ist kein Ersatz für Metriken oder Traces – es ist das dritte Standbein der Observability und das, das sich am besten für „was genau ist, der Reihe nach, bei dieser einen Anfrage passiert" eignet. Metriken beantworten billig „wie viele" über die Zeit; Traces beantworten „wohin ging die Zeit" über Dienste hinweg. Logs beantworten „welche konkreten Werte waren im Spiel". Nutze, was die Frage beantwortet, die du tatsächlich stellst – bei der konkreten fehlgeschlagenen Anfrage eines bestimmten Kunden sind das fast immer Logs.
Fazit
Der Wechsel von String-Logs zu strukturierten Logs ist zur Schreibzeit klein – ein Objekt übergeben, statt einen Satz zu bauen – und zur Lesezeit enorm, weil jedes Feld abfragbar wird, statt in Prosa vergraben zu sein. Kommt Kontext pro Anfrage dazu, bekommt man, was printf-Debugging nie konnte: jede Zeile einer Anfrage aus dem Feuerwehrschlauch aller anderen herauszuziehen, in einer Abfrage.