Skip to content

Instantly share code, notes, and snippets.

@trvswgnr
Created November 21, 2024 22:43
Show Gist options
  • Save trvswgnr/24447a12ef01a90e428b187af9b79b6b to your computer and use it in GitHub Desktop.
Save trvswgnr/24447a12ef01a90e428b187af9b79b6b to your computer and use it in GitHub Desktop.

Revisions

  1. trvswgnr created this gist Nov 21, 2024.
    430 changes: 430 additions & 0 deletions logger.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,430 @@
    /**
    * Syslog Facility codes as defined in RFC 5424
    */
    enum SyslogFacility {
    KERN = 0, // kernel messages
    USER = 1, // user-level messages
    MAIL = 2, // mail system
    DAEMON = 3, // system daemons
    AUTH = 4, // security/authorization messages
    SYSLOG = 5, // messages generated internally by syslogd
    LPR = 6, // line printer subsystem
    NEWS = 7, // network news subsystem
    UUCP = 8, // UUCP subsystem
    CRON = 9, // clock daemon
    AUTHPRIV = 10, // security/authorization messages
    FTP = 11, // FTP daemon
    NTP = 12, // NTP subsystem
    AUDIT = 13, // log audit
    ALERT = 14, // log alert
    CLOCK = 15, // clock daemon
    LOCAL0 = 16, // local use 0
    LOCAL1 = 17, // local use 1
    LOCAL2 = 18, // local use 2
    LOCAL3 = 19, // local use 3
    LOCAL4 = 20, // local use 4
    LOCAL5 = 21, // local use 5
    LOCAL6 = 22, // local use 6
    LOCAL7 = 23, // local use 7
    }

    /**
    * Syslog Severity levels as defined in RFC 5424
    */
    enum SyslogSeverity {
    EMERG = 0, // Emergency: system is unusable
    ALERT = 1, // Alert: action must be taken immediately
    CRIT = 2, // Critical: critical conditions
    ERR = 3, // Error: error conditions
    WARNING = 4, // Warning: warning conditions
    NOTICE = 5, // Notice: normal but significant condition
    INFO = 6, // Informational: informational messages
    DEBUG = 7, // Debug: debug-level messages
    }

    /**
    * Standard Syslog structured data IDs as defined in RFC 5424
    */
    enum StandardSD_ID {
    TIME_QUALITY = "timeQuality",
    ORIGIN = "origin",
    META = "meta",
    }

    /**
    * Time Quality parameters for structured data
    */
    interface TimeQualityParams {
    tzKnown?: "0" | "1";
    isSynced?: "0" | "1";
    syncAccuracy?: string;
    }

    /**
    * Origin parameters for structured data
    */
    interface OriginParams {
    ip?: string;
    enterpriseId?: string;
    software?: string;
    swVersion?: string;
    }

    /**
    * Meta parameters for structured data
    */
    interface MetaParams {
    sequenceId?: string;
    sysUpTime?: string;
    language?: string;
    }

    /**
    * Custom structured data parameters
    */
    interface CustomSD {
    [id: string]: {
    [param: string]: string;
    };
    }

    /**
    * Structured data type combining standard and custom elements
    */
    type StructuredData = Partial<{
    [StandardSD_ID.TIME_QUALITY]: TimeQualityParams;
    [StandardSD_ID.ORIGIN]: OriginParams;
    [StandardSD_ID.META]: MetaParams;
    }> &
    CustomSD;

    /**
    * Main syslog message interface with strongly typed fields
    */
    interface SyslogMessage {
    facility: SyslogFacility;
    severity: SyslogSeverity;
    timestamp?: Date;
    hostname?: string;
    appName?: string;
    procId?: string | number;
    msgId?: string;
    structuredData?: StructuredData;
    message?: string;
    }

    const NILVALUE = "-";
    const MAX_LENGTH = {
    HOSTNAME: 255,
    APP_NAME: 48,
    PROC_ID: 128,
    MSG_ID: 32,
    };

    /**
    * Formats a value according to syslog spec, applying length limits
    * and converting undefined/null to NILVALUE
    */
    function formatField(
    value: string | number | undefined | null,
    maxLength?: number,
    ): string {
    if (value === undefined || value === null) return NILVALUE;
    const stringValue = String(value);
    return maxLength ? stringValue.substring(0, maxLength) : stringValue;
    }

    /**
    * Escapes special characters in structured data parameter values
    */
    function escapeSDParam(value: string): string {
    return value
    .replace(/\\/g, "\\\\")
    .replace(/"/g, '\\"')
    .replace(/\]/g, "\\]");
    }

    /**
    * Formats structured data according to RFC 5424
    */
    function formatStructuredData(data: StructuredData | undefined): string {
    if (!data || Object.keys(data).length === 0) return NILVALUE;

    return Object.entries(data)
    .map(([sdId, params]) => {
    const formattedParams = Object.entries(params)
    .map(([name, value]) => `${name}="${escapeSDParam(String(value))}"`)
    .join(" ");
    return `[${sdId} ${formattedParams}]`;
    })
    .join("");
    }

    /**
    * Formats a syslog message according to RFC 5424
    * @throws {Error} If facility or severity values are invalid
    */
    function formatSyslogMessage(input: SyslogMessage): string {
    // Calculate priority value (PRI)
    const pri = input.facility * 8 + input.severity;

    // Format timestamp in RFC 5424 format with timezone
    const timestamp = input.timestamp
    ? input.timestamp.toISOString().replace(/\.(\d{3})Z$/, ".$1+00:00")
    : NILVALUE;

    // Format fields with proper length limits
    const hostname = formatField(input.hostname, MAX_LENGTH.HOSTNAME);
    const appName = formatField(input.appName, MAX_LENGTH.APP_NAME);
    const procId = formatField(input.procId, MAX_LENGTH.PROC_ID);
    const msgId = formatField(input.msgId, MAX_LENGTH.MSG_ID);

    // Format structured data
    const structuredData = formatStructuredData(input.structuredData);

    // Format message (if exists)
    const message = input.message ? ` ${input.message}` : "";

    // Assemble the final message according to RFC 5424 format
    return `<${pri}>1 ${timestamp} ${hostname} ${appName} ${procId} ${msgId} ${structuredData}${message}`;
    }

    const Syslog = {
    format: formatSyslogMessage,
    Facility: SyslogFacility,
    Severity: SyslogSeverity,
    StandardSD_ID,
    } as const;

    /**
    * Logger configuration options
    */
    interface LoggerOptions {
    /** Minimum severity level to log */
    minLevel?: SyslogSeverity;
    /** Application name to include in logs */
    appName?: string;
    /** Whether to use colors in console output */
    useColors?: boolean;
    /** Custom facility to use (defaults to USER) */
    facility?: SyslogFacility;
    }

    /**
    * Type for extra fields that can be included in log messages
    */
    type LogContext = Record<string, unknown>;

    /**
    * Color configurations for different severity levels
    */
    const COLORS = {
    [SyslogSeverity.EMERG]: "\x1b[41m\x1b[37m", // white on red background
    [SyslogSeverity.ALERT]: "\x1b[45m\x1b[37m", // white on magenta background
    [SyslogSeverity.CRIT]: "\x1b[41m\x1b[37m", // white on red background
    [SyslogSeverity.ERR]: "\x1b[31m", // red
    [SyslogSeverity.WARNING]: "\x1b[33m", // yellow
    [SyslogSeverity.NOTICE]: "\x1b[36m", // cyan
    [SyslogSeverity.INFO]: "\x1b[32m", // green
    [SyslogSeverity.DEBUG]: "\x1b[90m", // gray
    reset: "\x1b[0m",
    } as const;

    export class ConsoleLogger {
    private options: Required<LoggerOptions>;
    private hostname: string;
    private processId: string;

    constructor(options: LoggerOptions = {}) {
    this.options = {
    minLevel: SyslogSeverity.INFO,
    appName: "app",
    useColors: process.stdout.isTTY, // Auto-detect color support
    facility: SyslogFacility.USER,
    ...options,
    };

    this.hostname = this.getHostname();
    this.processId = process.pid.toString();
    }

    /**
    * Log methods for each severity level
    */
    public emergency(message: string, context?: LogContext) {
    this.log(SyslogSeverity.EMERG, message, context);
    }

    public alert(message: string, context?: LogContext) {
    this.log(SyslogSeverity.ALERT, message, context);
    }

    public critical(message: string, context?: LogContext) {
    this.log(SyslogSeverity.CRIT, message, context);
    }

    public error(message: string, context?: LogContext) {
    this.log(SyslogSeverity.ERR, message, context);
    }

    public warning(message: string, context?: LogContext) {
    this.log(SyslogSeverity.WARNING, message, context);
    }

    public notice(message: string, context?: LogContext) {
    this.log(SyslogSeverity.NOTICE, message, context);
    }

    public info(message: string, context?: LogContext) {
    this.log(SyslogSeverity.INFO, message, context);
    }

    public debug(message: string, context?: LogContext) {
    this.log(SyslogSeverity.DEBUG, message, context);
    }

    /**
    * Main logging method that uses our Syslog formatter
    */
    private log(
    severity: SyslogSeverity,
    message: string,
    context?: LogContext,
    ): void {
    // Check minimum log level
    if (severity > this.options.minLevel) return;

    // Create structured data from context
    const structuredData: StructuredData = {
    [StandardSD_ID.META]: {
    sequenceId: this.generateSequenceId(),
    },
    };

    if (context) {
    structuredData["context@0"] = this.flattenContext(context);
    }

    // Create syslog message
    const syslogMessage: SyslogMessage = {
    facility: this.options.facility,
    severity,
    timestamp: new Date(),
    hostname: this.hostname,
    appName: this.options.appName,
    procId: this.processId,
    msgId: this.generateMsgId(),
    structuredData,
    message,
    };

    // Format using our syslog formatter
    const formattedMessage = Syslog.format(syslogMessage);

    // Add colors if enabled and output
    if (this.options.useColors) {
    const color = COLORS[severity];
    const output = `${color}${formattedMessage}${COLORS.reset}`;
    this.writeToConsole(severity, output);
    } else {
    this.writeToConsole(severity, formattedMessage);
    }
    }

    /**
    * Write to appropriate console method based on severity
    */
    private writeToConsole(severity: SyslogSeverity, message: string): void {
    if (severity <= SyslogSeverity.ERR) {
    console.error(message);
    } else if (severity === SyslogSeverity.WARNING) {
    console.warn(message);
    } else if (severity === SyslogSeverity.DEBUG) {
    console.debug(message);
    } else {
    console.log(message);
    }
    }

    /**
    * Flatten context object for structured data
    */
    private flattenContext(
    context: LogContext,
    prefix = "",
    ): Record<string, string> {
    return Object.entries(context).reduce((acc, [key, value]) => {
    const fullKey = prefix ? `${prefix}_${key}` : key;

    if (value && typeof value === "object" && !Array.isArray(value)) {
    if (value instanceof Error) {
    return {
    // biome-ignore lint/performance/noAccumulatingSpread: <explanation>
    ...acc,
    [fullKey]: value.message,
    [`${fullKey}_stack`]: value.stack || "",
    };
    }
    // biome-ignore lint/performance/noAccumulatingSpread: <explanation>
    return { ...acc, ...this.flattenContext(value as LogContext, fullKey) };
    }

    // biome-ignore lint/performance/noAccumulatingSpread: <explanation>
    return { ...acc, [fullKey]: String(value) };
    }, {});
    }

    /**
    * Generate a unique message ID
    */
    private generateMsgId(): string {
    return `MSG-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`;
    }

    /**
    * Generate a sequence ID for the meta SD-ID
    */
    private generateSequenceId(): string {
    return Date.now().toString();
    }

    /**
    * Get system hostname
    */
    private getHostname(): string {
    try {
    return require("node:os").hostname();
    } catch {
    return "unknown-host";
    }
    }
    }

    // Example usage showing proper RFC 5424 formatting:
    function example() {
    const logger = new ConsoleLogger({
    appName: "MyApp",
    minLevel: SyslogSeverity.DEBUG,
    facility: SyslogFacility.LOCAL0,
    });

    // Will output RFC 5424 formatted logs with proper facility, severity, structured data, etc.
    logger.info("Application started", {
    version: "1.0.0",
    environment: "production",
    });

    logger.error("Database connection failed", {
    error: new Error("Connection timeout"),
    database: {
    host: "localhost",
    port: 5432,
    },
    });

    logger.info("Application started", {
    version: "1.0.0",
    environment: "production",
    });
    }
    example();