Initial import of verae-middleware from zapier monorepo
This commit is contained in:
commit
a11a20128a
67 changed files with 6630 additions and 0 deletions
106
src/debug/config.js
Normal file
106
src/debug/config.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* @fileoverview Runtime debug configuration.
|
||||
*
|
||||
* Reads process environment at call time of {@link loadDebugConfig} so tests can
|
||||
* mutate `process.env` between cases. Production servers typically load once at boot.
|
||||
*
|
||||
* @module debug/config
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DebugConfig
|
||||
* @property {boolean} enabled - True when any debug output should be produced.
|
||||
* @property {Set<string>|null} namespaces - Allowed namespaces; `null` means all.
|
||||
* @property {'debug'|'info'|'warn'|'error'} level - Minimum severity to emit.
|
||||
* @property {string|null} filePath - Optional secondary log file path.
|
||||
*/
|
||||
|
||||
const LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
|
||||
/**
|
||||
* Parse `DEBUG_VERAE` into an enabled flag and optional namespace allow-list.
|
||||
*
|
||||
* @param {string|undefined} raw - Raw env value (e.g. `"1"`, `"*"`, `"auth,nats"`).
|
||||
* @returns {{ enabled: boolean, namespaces: Set<string>|null }}
|
||||
*
|
||||
* @example
|
||||
* parseDebugVeraeEnv('auth,jobs'); // { enabled: true, namespaces: Set{'auth','jobs'} }
|
||||
* parseDebugVeraeEnv(undefined); // { enabled: false, namespaces: null }
|
||||
*/
|
||||
export function parseDebugVeraeEnv(raw) {
|
||||
if (raw === undefined || raw === null) {
|
||||
return { enabled: false, namespaces: null };
|
||||
}
|
||||
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed || trimmed === '0' || trimmed.toLowerCase() === 'false' || trimmed.toLowerCase() === 'off') {
|
||||
return { enabled: false, namespaces: null };
|
||||
}
|
||||
|
||||
if (trimmed === '1' || trimmed === '*' || trimmed.toLowerCase() === 'true' || trimmed.toLowerCase() === 'all') {
|
||||
return { enabled: true, namespaces: null };
|
||||
}
|
||||
|
||||
const parts = trimmed
|
||||
.split(',')
|
||||
.map((p) => p.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return { enabled: false, namespaces: null };
|
||||
}
|
||||
|
||||
return { enabled: true, namespaces: new Set(parts) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a level string to a known level.
|
||||
*
|
||||
* @param {string|undefined} raw - Env level value.
|
||||
* @param {'debug'|'info'|'warn'|'error'} [fallback='debug'] - Default level.
|
||||
* @returns {'debug'|'info'|'warn'|'error'}
|
||||
*/
|
||||
export function parseLevel(raw, fallback = 'debug') {
|
||||
const value = String(raw ?? fallback).toLowerCase();
|
||||
if (value in LEVEL_ORDER) return /** @type {'debug'|'info'|'warn'|'error'} */ (value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full debug configuration from `process.env`.
|
||||
*
|
||||
* Environment variables:
|
||||
* - `DEBUG_VERAE` — enable + namespaces (see {@link parseDebugVeraeEnv})
|
||||
* - `DEBUG_VERAE_LEVEL` — minimum level
|
||||
* - `DEBUG_VERAE_FILE` — optional log file path
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv} [env=process.env] - Environment map (injectable for tests).
|
||||
* @returns {DebugConfig}
|
||||
*/
|
||||
export function loadDebugConfig(env = process.env) {
|
||||
const { enabled, namespaces } = parseDebugVeraeEnv(env.DEBUG_VERAE);
|
||||
return {
|
||||
enabled,
|
||||
namespaces,
|
||||
level: parseLevel(env.DEBUG_VERAE_LEVEL, 'debug'),
|
||||
filePath: env.DEBUG_VERAE_FILE ? String(env.DEBUG_VERAE_FILE) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a message at `messageLevel` in `namespace` should be emitted.
|
||||
*
|
||||
* @param {DebugConfig} config - Active config.
|
||||
* @param {string} namespace - Logger namespace (e.g. `auth`).
|
||||
* @param {'debug'|'info'|'warn'|'error'} messageLevel - Message severity.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldLog(config, namespace, messageLevel) {
|
||||
if (!config.enabled) return false;
|
||||
if (config.namespaces && !config.namespaces.has(String(namespace).toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return LEVEL_ORDER[messageLevel] >= LEVEL_ORDER[config.level];
|
||||
}
|
||||
|
||||
export { LEVEL_ORDER };
|
||||
24
src/debug/index.js
Normal file
24
src/debug/index.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* @fileoverview Public exports for the runtime debug facility.
|
||||
* @module debug
|
||||
*/
|
||||
|
||||
export {
|
||||
loadDebugConfig,
|
||||
parseDebugVeraeEnv,
|
||||
parseLevel,
|
||||
shouldLog,
|
||||
LEVEL_ORDER,
|
||||
} from './config.js';
|
||||
|
||||
export { createDebugger, formatLogLine, setDebugTestSink } from './logger.js';
|
||||
|
||||
export { redact, redactString } from './redact.js';
|
||||
|
||||
export {
|
||||
generateTraceId,
|
||||
getTraceId,
|
||||
getTraceContext,
|
||||
withTrace,
|
||||
traceMiddleware,
|
||||
} from './trace.js';
|
||||
121
src/debug/logger.js
Normal file
121
src/debug/logger.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* @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),
|
||||
};
|
||||
}
|
||||
93
src/debug/redact.js
Normal file
93
src/debug/redact.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* @fileoverview Secret redaction for debug logs.
|
||||
* @module debug/redact
|
||||
*/
|
||||
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'password',
|
||||
'veraepassword',
|
||||
'token',
|
||||
'veraetoken',
|
||||
'accesstoken',
|
||||
'apikey',
|
||||
'api_key',
|
||||
'secret',
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'cookie',
|
||||
'sessionkey',
|
||||
]);
|
||||
|
||||
const BEARER_RE = /^Bearer\s+.+/i;
|
||||
const API_KEY_RE = /^zmw_[A-Za-z0-9_-]+/;
|
||||
const SESSION_RE = /^zmt_[A-Za-z0-9_.-]+/;
|
||||
const JWT_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/;
|
||||
|
||||
/**
|
||||
* Redact a single string value if it looks like a secret.
|
||||
*
|
||||
* @param {string} value - Raw string.
|
||||
* @returns {string} Original or `[REDACTED]`.
|
||||
*/
|
||||
export function redactString(value) {
|
||||
if (typeof value !== 'string') return value;
|
||||
if (
|
||||
BEARER_RE.test(value) ||
|
||||
API_KEY_RE.test(value) ||
|
||||
SESSION_RE.test(value) ||
|
||||
JWT_RE.test(value)
|
||||
) {
|
||||
return '[REDACTED]';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone plain data structures while redacting sensitive keys and values.
|
||||
*
|
||||
* @param {unknown} input - Any JSON-like value.
|
||||
* @param {number} [depth=0] - Recursion depth guard.
|
||||
* @returns {unknown} Redacted structure safe for logging.
|
||||
*
|
||||
* @example
|
||||
* redact({ password: 'x', jobId: '1' }); // { password: '[REDACTED]', jobId: '1' }
|
||||
*/
|
||||
export function redact(input, depth = 0) {
|
||||
if (depth > 8) return '[MaxDepth]';
|
||||
|
||||
if (input === null || input === undefined) return input;
|
||||
|
||||
if (typeof input === 'string') return redactString(input);
|
||||
|
||||
if (typeof input === 'number' || typeof input === 'boolean') return input;
|
||||
|
||||
if (input instanceof Error) {
|
||||
return { name: input.name, message: redactString(input.message), stack: undefined };
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => redact(item, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof input === 'object') {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (SENSITIVE_KEYS.has(key.toLowerCase())) {
|
||||
out[key] = '[REDACTED]';
|
||||
} else if (key.toLowerCase() === 'targeturl' && typeof value === 'string') {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
out[key] = `${u.origin}${u.pathname}`;
|
||||
} catch {
|
||||
out[key] = '[REDACTED_URL]';
|
||||
}
|
||||
} else {
|
||||
out[key] = redact(value, depth + 1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return String(input);
|
||||
}
|
||||
39
src/debug/trace-context.js
Normal file
39
src/debug/trace-context.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* @fileoverview AsyncLocalStorage-backed trace context (no logger dependency).
|
||||
* Split from trace.js to avoid circular imports with logger.js.
|
||||
* @module debug/trace-context
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* @typedef {Object} TraceContext
|
||||
* @property {string} traceId
|
||||
* @property {string} [span]
|
||||
*/
|
||||
|
||||
/** @type {AsyncLocalStorage<TraceContext>} */
|
||||
export const traceStorage = new AsyncLocalStorage();
|
||||
|
||||
/**
|
||||
* Generate a short opaque trace id (16 hex chars).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateTraceId() {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getTraceId() {
|
||||
return traceStorage.getStore()?.traceId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {TraceContext|null}
|
||||
*/
|
||||
export function getTraceContext() {
|
||||
return traceStorage.getStore() ?? null;
|
||||
}
|
||||
95
src/debug/trace.js
Normal file
95
src/debug/trace.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* @fileoverview Correlation / trace IDs for failure tracing across HTTP and NATS.
|
||||
* @module debug/trace
|
||||
*/
|
||||
|
||||
import { createDebugger } from './logger.js';
|
||||
import {
|
||||
traceStorage,
|
||||
generateTraceId,
|
||||
getTraceId,
|
||||
getTraceContext,
|
||||
} from './trace-context.js';
|
||||
|
||||
export { generateTraceId, getTraceId, getTraceContext };
|
||||
|
||||
const log = createDebugger('trace');
|
||||
|
||||
/**
|
||||
* Run `fn` within a trace context. Nested calls inherit the same `traceId`
|
||||
* unless `traceId` is explicitly overridden.
|
||||
*
|
||||
* @template T
|
||||
* @param {object|(() => T|Promise<T>)} options - Options or the callback itself.
|
||||
* @param {string} [options.traceId] - Existing id (e.g. from `X-Trace-Id` header).
|
||||
* @param {string} [options.span] - Human label for this span (logged when debug on).
|
||||
* @param {() => T|Promise<T>} [fn] - Work to execute inside the context when options is an object.
|
||||
* @returns {T|Promise<T>} Return value of `fn`.
|
||||
*
|
||||
* @example
|
||||
* await withTrace({ span: 'timestamp.wait', traceId: req.headers['x-trace-id'] }, async () => {
|
||||
* // getTraceId() is stable here and in awaited children
|
||||
* });
|
||||
*/
|
||||
export function withTrace(options, fn) {
|
||||
const opts = typeof options === 'function' ? {} : options ?? {};
|
||||
const callback = typeof options === 'function' ? options : fn;
|
||||
|
||||
if (typeof callback !== 'function') {
|
||||
throw new TypeError('withTrace requires a function to execute');
|
||||
}
|
||||
|
||||
const parent = traceStorage.getStore();
|
||||
const traceId = opts.traceId || parent?.traceId || generateTraceId();
|
||||
const span = opts.span || parent?.span || 'root';
|
||||
|
||||
const ctx = { traceId, span };
|
||||
|
||||
return traceStorage.run(ctx, () => {
|
||||
log.debug('span enter', { span });
|
||||
try {
|
||||
const result = callback();
|
||||
if (result && typeof result.then === 'function') {
|
||||
return result.then(
|
||||
(value) => {
|
||||
log.debug('span exit', { span, ok: true });
|
||||
return value;
|
||||
},
|
||||
(err) => {
|
||||
log.debug('span exit', { span, ok: false, error: err?.message });
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
}
|
||||
log.debug('span exit', { span, ok: true });
|
||||
return result;
|
||||
} catch (err) {
|
||||
log.debug('span exit', { span, ok: false, error: err?.message });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware that establishes a trace context per request.
|
||||
*
|
||||
* Reads `X-Trace-Id` when present; otherwise generates a new id.
|
||||
* Sets `req.traceId` and response header `X-Trace-Id`.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
* @returns {void}
|
||||
*/
|
||||
export function traceMiddleware(req, res, next) {
|
||||
const incoming = req.headers['x-trace-id'];
|
||||
const traceId =
|
||||
typeof incoming === 'string' && incoming.trim() ? incoming.trim() : generateTraceId();
|
||||
|
||||
req.traceId = traceId;
|
||||
res.setHeader('X-Trace-Id', traceId);
|
||||
|
||||
withTrace({ traceId, span: `${req.method} ${req.path}` }, () => {
|
||||
next();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue