File size: 5,301 Bytes
4e23b01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | import type { LogContext, LogEntry } from './types';
export const MSG_MAX_CHARS = 200;
export const CTX_VALUE_MAX_CHARS = 2048;
export const STACK_MAX_BYTES = 2048;
export const ENTRY_MAX_BYTES = 4096;
export const REDACT_MAX_DEPTH = 10;
const REDACTED_KEYS: ReadonlySet<string> = new Set([
'authorization',
'apikey',
'token',
'refreshtoken',
'accesstoken',
'idtoken',
'password',
'secret',
'clientsecret',
'apisecret',
'cookie',
'setcookie',
'bearer',
]);
const SAFE_KEY_RE = /^[\w.-]+$/;
const ELLIPSIS = '…';
const TRUNCATED_TAIL = ` …truncated`;
const REDACTED = '[REDACTED]';
const RAW_SECRET_PATTERNS: readonly RegExp[] = [
/\b(authorization\s*[:=]\s*bearer\s+)[^\s"'`]+/gi,
/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret)\s*[:=]\s*)[^\s"'`]+/gi,
/\b(cookie\s*[:=]\s*)[^\r\n]+/gi,
];
const LEVEL_LABEL: Record<Exclude<LogEntry['level'], never>, string> = {
error: 'ERROR',
warn: 'WARN ',
info: 'INFO ',
debug: 'DEBUG',
};
function normalizeKey(key: string): string {
return key.toLowerCase().replaceAll(/[_\-.]/g, '');
}
export function redactCtx(ctx: LogContext): LogContext {
const seen = new WeakSet<object>();
const walk = (value: unknown, depth: number): unknown => {
if (depth > REDACT_MAX_DEPTH) return '[REDACTED:depth]';
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return '[REDACTED:cycle]';
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => walk(item, depth + 1));
}
const out: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
out[key] = REDACTED_KEYS.has(normalizeKey(key))
? REDACTED
: walk(raw, depth + 1);
}
return out;
};
return walk(ctx, 0) as LogContext;
}
export interface FormattedEntry {
readonly text: string;
readonly dropped: boolean;
}
function truncate(value: string, max: number): string {
return value.length <= max ? value : value.slice(0, max - 1) + ELLIPSIS;
}
function serializeValue(raw: unknown): string {
if (typeof raw === 'string') return redactString(raw);
if (raw === undefined) return 'undefined';
if (raw === null) return 'null';
if (
typeof raw === 'number' ||
typeof raw === 'boolean' ||
typeof raw === 'bigint' ||
typeof raw === 'symbol'
) {
return String(raw);
}
try {
const json = JSON.stringify(raw);
if (json !== undefined) return json;
} catch {
}
if (typeof raw === 'function') return raw.name === '' ? '[Function]' : `[Function: ${raw.name}]`;
return Object.prototype.toString.call(raw);
}
function redactString(value: string): string {
let out = value;
for (const pattern of RAW_SECRET_PATTERNS) {
out = out.replace(pattern, `$1${REDACTED}`);
}
return out;
}
function quote(value: string): string {
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`;
}
function formatPair(key: string, raw: unknown): string {
const limited = truncate(serializeValue(raw), CTX_VALUE_MAX_CHARS);
const renderedKey = SAFE_KEY_RE.test(key) ? key : quote(key);
const renderedVal = /[\s="\\]/.test(limited) || limited.length === 0 ? quote(limited) : limited;
return `${renderedKey}=${renderedVal}`;
}
function clipBytes(text: string, maxBytes: number): string {
if (Buffer.byteLength(text, 'utf-8') <= maxBytes) return text;
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (
Buffer.byteLength(text.slice(0, mid), 'utf-8') <=
maxBytes - Buffer.byteLength(TRUNCATED_TAIL, 'utf-8')
) {
lo = mid;
} else {
hi = mid - 1;
}
}
return text.slice(0, lo) + TRUNCATED_TAIL;
}
function clipStack(stack: string): string {
if (Buffer.byteLength(stack, 'utf-8') <= STACK_MAX_BYTES) return stack;
return clipBytes(stack, STACK_MAX_BYTES);
}
function indentStack(stack: string): string {
return stack
.split('\n')
.map((line, i) => (i === 0 ? ` ${line}` : ` ${line.trimStart()}`))
.join('\n');
}
export function formatEntry(entry: LogEntry): FormattedEntry {
const ctx = entry.ctx ? redactCtx(entry.ctx) : undefined;
const msg = truncate(entry.msg, MSG_MAX_CHARS);
const pairs: string[] = [];
if (ctx) {
for (const [k, v] of Object.entries(ctx)) {
if (v !== undefined) pairs.push(formatPair(k, v));
}
}
const time = new Date(entry.t).toISOString();
const label = LEVEL_LABEL[entry.level];
const rendered = pairs.length === 0
? `${time} ${label} ${msg}`
: `${time} ${label} ${msg} ${pairs.join(' ')}`;
let head = Buffer.byteLength(rendered, 'utf-8') > ENTRY_MAX_BYTES
? clipBytes(rendered, ENTRY_MAX_BYTES)
: rendered;
if (entry.error?.stack) {
head = `${head}\n${indentStack(clipStack(redactString(entry.error.stack)))}`;
} else if (entry.error?.message) {
head = `${head}\n Error: ${redactString(entry.error.message)}`;
}
return { text: head, dropped: false };
}
export function extractError(value: Error): { message: string; stack?: string } {
return typeof value.stack === 'string'
? { message: value.message, stack: value.stack }
: { message: value.message };
}
|