Files
wiki-print/src/logger.js
T
2026-11-13 11:50:48 +01:00

144 lines
4.0 KiB
JavaScript

const LEVELS = Object.freeze({
debug: 10,
info: 20,
warn: 30,
error: 40,
silent: 100,
})
const REDACTED = '[redacted]'
const SECRET_KEY_RE = /(^|_)(api[-_]?key|authorization|password|secret|token|credential)($|_)/i
const MAX_STRING_LENGTH = 2000
function normalizeLevel(value, fallback = 'info') {
const level = String(value ?? '').trim().toLowerCase()
return Object.hasOwn(LEVELS, level) ? level : fallback
}
function normalizeFormat(value) {
return String(value ?? '').trim().toLowerCase() === 'json' ? 'json' : 'text'
}
function sanitizeValue(value) {
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
code: value.code,
status: value.status,
stack: value.stack,
}
}
if (typeof value === 'string') {
return value.length > MAX_STRING_LENGTH
? `${value.slice(0, MAX_STRING_LENGTH)}… (${value.length - MAX_STRING_LENGTH} more characters)`
: value
}
if (typeof value === 'bigint') return value.toString()
if (Array.isArray(value)) return value.map(sanitizeValue)
if (value && typeof value === 'object') {
const result = {}
for (const [key, entry] of Object.entries(value)) {
result[key] = SECRET_KEY_RE.test(key) ? REDACTED : sanitizeValue(entry)
}
return result
}
return value
}
function formatScalar(value) {
if (value === null) return 'null'
if (typeof value === 'string') {
return /^[A-Za-z0-9_./:@[\]-]+$/.test(value) ? value : JSON.stringify(value)
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
function formatText(timestamp, level, message, fields) {
const parts = [timestamp, level.toUpperCase().padEnd(5), message]
for (const [key, value] of Object.entries(fields)) {
if (value === undefined) continue
parts.push(`${key}=${formatScalar(value)}`)
}
return `${parts.join(' ')}\n`
}
function formatJson(timestamp, level, message, fields) {
return `${JSON.stringify({ timestamp, level, message, ...fields })}\n`
}
/**
* Create a small dependency-free logger.
*
* LOG_LEVEL: debug | info | warn | error | silent (default: info)
* LOG_FORMAT: text | json (default: text)
*
* All output goes to stderr so the CLI can keep stdout for the PDF path.
*/
export function createLogger({
level = process.env.LOG_LEVEL,
format = process.env.LOG_FORMAT,
stream = process.stderr,
bindings = {},
} = {}) {
const configuredLevel = normalizeLevel(level)
const configuredFormat = normalizeFormat(format)
const threshold = LEVELS[configuredLevel]
const parentBindings = { ...bindings }
function write(logLevel, message, fields = {}) {
if (LEVELS[logLevel] < threshold) return
const timestamp = new Date().toISOString()
const merged = { ...parentBindings, ...fields }
const sanitized = {}
for (const [key, value] of Object.entries(merged)) {
if (value === undefined) continue
sanitized[key] = SECRET_KEY_RE.test(key) ? REDACTED : sanitizeValue(value)
}
const line =
configuredFormat === 'json'
? formatJson(timestamp, logLevel, String(message), sanitized)
: formatText(timestamp, logLevel, String(message), sanitized)
try {
stream.write(line)
} catch {
// Logging must never break an export.
}
}
return {
level: configuredLevel,
format: configuredFormat,
bindings: parentBindings,
isLevelEnabled(logLevel) {
return LEVELS[logLevel] >= threshold
},
child(extraBindings = {}) {
return createLogger({
level: configuredLevel,
format: configuredFormat,
stream,
bindings: { ...parentBindings, ...extraBindings },
})
},
debug(message, fields) {
write('debug', message, fields)
},
info(message, fields) {
write('info', message, fields)
},
warn(message, fields) {
write('warn', message, fields)
},
error(message, fields) {
write('error', message, fields)
},
}
}
export const logger = createLogger({ bindings: { app: 'wiki-print' } })