recipe-manager/logger.js
Chris 423334db4d Add structured logging (Pino), Docker log rotation, and OpenObserve + Fluent Bit observability stack
- logger.js: Pino JSON logger with console-style helpers and pino-http request logging (static assets skipped, headers not logged, secrets redacted)

- server.js: replace 89 console.* calls with log.info/warn/error; add request logging middleware

- docker-compose.yml: json-file rotation (10m x 5, compressed), LOG_LEVEL, 'forkfolio' log tag

- observability/: standalone OpenObserve + Fluent Bit stack routing tagged container logs to per-app streams

- .dockerignore: exclude observability stack from app image
2026-11-01 01:17:21 -05:00

89 lines
2.9 KiB
JavaScript

/**
* Centralized structured logging for ForkFolio.
*
* - Emits newline-delimited JSON to stdout (collected by Docker, rotated by the
* json-file log driver, and forwarded to OpenObserve by Fluent Bit).
* - `log.*` helpers accept console-style arguments, so existing
* `console.error('Message:', err)` call sites translate 1:1 while Error
* objects are serialized with their stack trace under the `err` key.
* - `httpLogger` is Express middleware that logs one line per HTTP request.
*
* Environment variables:
* LOG_LEVEL trace | debug | info | warn | error | fatal | silent (default: info)
*/
const crypto = require('crypto');
const util = require('util');
const pino = require('pino');
const pinoHttp = require('pino-http');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { app: 'forkfolio' },
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
// Use readable level names ("info") instead of numeric levels (30)
level: (label) => ({ level: label })
},
redact: {
paths: ['password', '*.password', 'token', '*.token', 'apiKey', '*.apiKey'],
censor: '[REDACTED]'
}
});
/**
* Builds a console-compatible log function for the given pino level.
* The first Error argument is attached as `err`; remaining arguments are
* formatted into the message exactly like console.log would format them.
*/
function consoleStyle(level) {
return (...args) => {
const err = args.find((a) => a instanceof Error);
const rest = err ? args.filter((a) => a !== err) : args;
const msg = util.format(...rest);
if (err) {
// Drop the trailing "Message:" colon since the error is attached separately
logger[level]({ err }, msg.replace(/:\s*$/, ''));
} else {
logger[level](msg);
}
};
}
const log = {
debug: consoleStyle('debug'),
info: consoleStyle('info'),
warn: consoleStyle('warn'),
error: consoleStyle('error')
};
// Static asset requests are high-volume and low-value; skip them in request logs
const STATIC_ASSET_RE = /\.(?:js|css|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|map|json|html)$/i;
const httpLogger = pinoHttp({
logger,
genReqId: (req, res) => {
const id = req.headers['x-request-id'] || crypto.randomUUID();
res.setHeader('X-Request-Id', id);
return id;
},
autoLogging: {
ignore: (req) => STATIC_ASSET_RE.test((req.url || '').split('?')[0])
},
customLogLevel: (req, res, err) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
// Keep request logs compact and never record headers (Authorization, cookies)
serializers: {
req: (req) => ({
id: req.id,
method: req.method,
url: req.url,
ip: (req.headers && req.headers['x-forwarded-for']) || req.remoteAddress
}),
res: (res) => ({ statusCode: res.statusCode })
}
});
module.exports = { logger, log, httpLogger };