Node.js und Logging – Teil 4: Konfiguration des LogServices

In diesem Artikel konfigurieren wir unseren LogService vollständig über Umgebungsvariablen. Aufbauend auf den vorherigen Teilen steuern wir LogLevel und LogFormat zentral per EnvVarService und machen das Logging damit umgebungsabhängig und flexibel. Von typensicheren Enums bis zur sauberen Winston-Integration entsteht eine Lösung, die sowohl lokal als auch im Monitoring überzeugt.

5 Minuten

Heute wollen wir unseren in Teil 1 entwickelten LogService mithilfe des aus Teil 2 hervorgegangenen EnvVarService konfigurieren. Dafür werden wir Umgebungsvariablen gemäß der Vorarbeit aus Teil 3 definieren. In der Konfiguration greifen wir auf:

  • LogLevel: damit wir z. B. in der Produktivumgebung nur auf Level Error und in der Testumgebung auf Debug-Level loggen.
  • Log-Ausgabeformat: Wir bereiten zwei Varianten vor. Eine Variante für zeilenbasierte Ausgabe und eine zweite als reine JSON-Ausgabe. Letztere lässt sich in der Weiterverarbeitung einfach in Monitoring-Systeme wie Loki integrieren.

Neue Typdefinition

Für den Loglevel definieren wir ein Enum, um es im EnvVarService zur Eingabeprüfung zu verwenden.

// in /src/types.ts
export enum LogLevels {  
  CRITICAL = 'critical',  
  ERROR = 'error',  
  WARN = 'warn',  
  INFO = 'info',  
  VERBOSE = 'verbose',  
  DEBUG = 'debug',  
}  

export type LogLevel = typeof LogLevels[keyof typeof LogLevels]

Genauso verfahren wir mit dem Logformat und erstellen ein Enum sowie die Typdefinition.

// in /src/types.ts
export enum LogFormats {  
  PLAIN = 'plain',  
  JSON = 'json',  
}  

export type LogFormat = typeof LogFormats[keyof typeof LogFormats]

Neue Umgebungsvariablen

Entsprechend nutzen wir die Umgebungsvariablen LOG_LEVEL und LOG_FORMAT. Die Ergänzung in der .env.dist als Dokumentation für unser Zukunfts-Ich darf nicht fehlen:

# Log levels as defined in our enurm: critical, error, warn, info, verbose, debug  
LOG_LEVEL=  
# Log formats as defined in our enum: "plain" and "json"
LOG_FORMAT=

Erweiterung des EnvVarServices

Für den EnvVarService erstellen wir einen neuen Kontext „log“ und füllen ihn mit dem passenden Parser für Umgebungsvariablen.

const parseLogValues = () => {  
  // Base schema for all log-related environment variables  
  const schema = z.object({  
    LOG_LEVEL: withDevDefault(z.enum(LogLevels), LogLevels.DEBUG),  
    LOG_FORMAT: withDevDefault(z.enum(LogFormats), LogFormats.PLAIN),  
  })  

  const parsed = schema.safeParse(process.env)  

  // handle error, so app doesn't start with invalid env vars  
  if (!parsed.success) {  
    throw new Error(  
      `❌ Invalid environment variables in log schema: ${JSON.stringify(parsed.error, null, 2)}`,  
    )  
  }  

  return {  
    logLevel: parsed.data.LOG_LEVEL,  
    logFormat: parsed.data.LOG_FORMAT,  
  }  
}

// Export the parsed env vars per context: e.g. app or logging  
export default {  
  app: parseBaseValues(),  
  logging: parseLogValues(),  
}

Einbindung in LogService

Für die Einbindung in den LogService haben wir die Typen für LogServiceArgs vorbereitet. Diesen ergänzen wir um die beiden Werte und passen den Aufruf der Factory an:

import EnvVarService from '@/services/EnvVarService'

type LogServiceArgs = {  
  logLevel: LogLevel  
  logFormat: LogFormat  
}

const LogService = ServiceFactory({  
  logLevel: EnvVarService.logging.logLevel,  
  logFormat: EnvVarService.logging.logFormat,  
})  
export default LogService

Die neuen Argumente nutzen wir bei der Erstellung des Loggers, indem wir den LogLevel statt fest auf Debug auf level: args.logLevel setzen. Beim Format müssen wir mehr Arbeit investieren:

  • Wir definieren zwei Hilfsmethoden, die anhand eines Switch-Statements das passende Format zurückgeben.
  • Für das attachMetadata im Plain-Format benötigen wir ein Pendant im JSON-Format. Dieser fällt jedoch deutlich schlanker aus.
// The same attachMetadata function but for JSON format  
const attachMetadataToJson = winston.format((info) => {  
  const splat = info[Symbol.for('splat')]  
  return {...info, splat}  
})

// Format for unhandled exceptions and rejections  
const getUnhandledFormat = (logFormat: LogFormat) => {  
  switch (logFormat) {  
    case LogFormats.JSON:  
      return winston.format.combine(  
        winston.format.timestamp(), // To add timestamp to log entries as ISO string  
        winston.format.json()  
      )  
    case LogFormats.PLAIN:  
    default:  
      return winston.format.combine(  
        winston.format.timestamp(), // To add timestamp to log entries as ISO string  
        winston.format.errors({stack: false}), // We receive all data by default, no stack trace needed here  
        winston.format.prettyPrint({colorize: true}), // To make unhandled Error output readable in console with indention  
      )  
  }  
}  

// Default format for regular log entries  
const getDefaultFormat = (format: LogFormat) => {  
  switch (format) {  
    case LogFormats.JSON:  
      return winston.format.combine(  
        winston.format.timestamp(), // To add timestamp to log entries as ISO string  
        attachMetadataToJson(), // To attach metadata from splat to the message  
        winston.format.json()  
      )  
    case LogFormats.PLAIN:  
    default:  
      return winston.format.combine(  
        winston.format.colorize({all: true}), // To colorize the output based on log level  
        winston.format.timestamp(), // To add timestamp to log entries as ISO string  
        winston.format.errors({stack: true}), // To log JavaScript Error with stack trace  
        attachMetadata(), // To attach metadata from splat to the message  
        logFormat, // Our custom log format defined above as [timestamp] level message  
      )  
  }  
}

Der Aufruf zum Erstellen des Loggers ergibt sich entsprechend:

// To make this definition readable, we move extensive configurations to variables/functions above  
const logger = winston.createLogger({  
  levels: logLevelDefinition,  
  level: args.logLevel,  
  exitOnError: false, // handled exceptions will not cause process.exit  
  transports: [new winston.transports.Console({format: getDefaultFormat(args.logFormat)})],  
  format: getUnhandledFormat(args.logFormat), // Format for unhandled exceptions and rejections. Will be used by exceptionHandlers below  
  exceptionHandlers: [new winston.transports.Console()], // Will use the format defined above: unhandledFormat  
  rejectionHandlers: [new winston.transports.Console()], // Will use the format defined above: unhandledFormat  
})

Fazit

Mit der aktuellen Implementierung können wir über Umgebungsvariablen den LogLevel und das LogFormat konfigurieren. Dieses Codestück ist eines der wenigen, die ich nicht teste. Es mag funktionieren, Winston zu mocken und mithilfe von Spies die Aufrufe zu testen. Nur erschließt sich mir damit kein direkter Mehrwert.

„CodeCover Fanatics: 100 % Testabdeckung“ oder „Pragmatiker: 70–90 % reichen“ – ich gehöre zu den Pragmatikern.

Aus welchem Lager kommst du?

Im nächsten Artikel stellen wir sicher, dass die Logausgaben bei den Testläufen ausgestellt werden. Mit unserer Vorbereitung sollte das ein Leichtes sein.

call to action background image

Abonniere meinen Newsletter

Erhalte einmal im Monat Nachrichten aus den Bereichen Softwareentwicklung und Kommunikation gespikt mit Buch- und Linkempfehlungen.