verae-middleware/src/debug/logger.js

121 lines
3.5 KiB
JavaScript

/**
* @fileoverview Namespaced debug logger with runtime enable/disable.
* @module debug/logger
*/
import { appendFileSync } from 'node:fs';
import { loadDebugConfig, shouldLog } from './config.js';
import { redact } from './redact.js';
import { getTraceId } from './trace-context.js';
/**
* @typedef {Object} Debugger
* @property {(msg: string, meta?: Record<string, unknown>) => void} debug
* @property {(msg: string, meta?: Record<string, unknown>) => void} info
* @property {(msg: string, meta?: Record<string, unknown>) => void} warn
* @property {(msg: string, meta?: Record<string, unknown>) => void} error
* @property {string} namespace
*/
/**
* Optional sink for tests — when set, lines go here instead of/in addition to stderr.
* @type {null|((line: string) => void)}
*/
let testSink = null;
/**
* Install a test sink that captures formatted log lines.
* Used only by unit tests; do not use in production code.
*
* @param {null|((line: string) => void)} sink - Callback or null to clear.
* @returns {void}
*/
export function setDebugTestSink(sink) {
testSink = sink;
}
/**
* Format a single log line.
*
* @param {object} parts
* @param {string} parts.level
* @param {string} parts.namespace
* @param {string} parts.message
* @param {Record<string, unknown>|undefined} parts.meta
* @param {string|null} parts.traceId
* @returns {string}
*/
export function formatLogLine({ level, namespace, message, meta, traceId }) {
const ts = new Date().toISOString();
const tracePart = traceId ? ` [trace=${traceId}]` : '';
const metaPart =
meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(redact(meta))}` : '';
return `${ts} ${level.toUpperCase()} ${namespace}${tracePart} ${message}${metaPart}`;
}
/**
* Emit a log line to stderr, optional file, and optional test sink.
*
* @param {string} line - Full formatted line.
* @param {string|null} filePath - Optional file from config.
* @returns {void}
*/
function emit(line, filePath) {
if (testSink) {
testSink(line);
} else {
// eslint-disable-next-line no-console
console.error(line);
}
if (filePath) {
try {
appendFileSync(filePath, `${line}\n`, 'utf8');
} catch {
// ignore file errors in debug path
}
}
}
/**
* Create a namespaced debugger bound to current env configuration.
*
* Configuration is re-read on every log call so tests can toggle `DEBUG_VERAE`
* without reloading modules. Slight cost is acceptable for debug-only paths.
*
* @param {string} namespace - Namespace such as `auth`, `nats`, `jobs`.
* @returns {Debugger} Logger with debug/info/warn/error methods.
*
* @example
* const log = createDebugger('jobs');
* log.debug('poll start', { jobId: 'abc' });
*/
export function createDebugger(namespace) {
const ns = String(namespace || 'app').toLowerCase();
/**
* @param {'debug'|'info'|'warn'|'error'} level
* @param {string} message
* @param {Record<string, unknown>} [meta]
*/
function write(level, message, meta) {
const config = loadDebugConfig();
if (!shouldLog(config, ns, level)) return;
const line = formatLogLine({
level,
namespace: ns,
message: String(message),
meta,
traceId: getTraceId(),
});
emit(line, config.filePath);
}
return {
namespace: ns,
debug: (message, meta) => write('debug', message, meta),
info: (message, meta) => write('info', message, meta),
warn: (message, meta) => write('warn', message, meta),
error: (message, meta) => write('error', message, meta),
};
}