ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
228 lines
6.6 KiB
TypeScript
228 lines
6.6 KiB
TypeScript
/**
|
|
* Structured logger for server-side code.
|
|
*
|
|
* Emits JSON in production (Vercel logs ingest these), pretty text in dev.
|
|
* Suppresses info/warn in test (preserves existing test-noise contract).
|
|
*
|
|
* Backward-compatible with the legacy `log.error(msg, ...args)` callers: any
|
|
* extra args after the message are merged into the structured payload:
|
|
* - Error instances become `err: { name, message, stack, code }`
|
|
* - plain objects merge into the context fields (after PII redaction)
|
|
* - everything else goes into `details: [...]`
|
|
*
|
|
* New code should prefer the explicit ctx form: `log.error('msg', err, ctx)`.
|
|
*
|
|
* Use `log.child({ requestId, companyId, ... })` to bind a context that is
|
|
* merged into every subsequent call. The `with-route-context` wrapper relies
|
|
* on this to thread requestId through a request lifecycle.
|
|
*/
|
|
|
|
type LogLevel = 'info' | 'warn' | 'error'
|
|
|
|
export interface LogContext {
|
|
requestId?: string
|
|
userId?: string
|
|
companyId?: string
|
|
operation?: string
|
|
entityType?: string
|
|
entityId?: string
|
|
durationMs?: number
|
|
[k: string]: unknown
|
|
}
|
|
|
|
export interface Logger {
|
|
info(message: string, ...args: unknown[]): void
|
|
warn(message: string, ...args: unknown[]): void
|
|
error(message: string, ...args: unknown[]): void
|
|
child(extra: LogContext): Logger
|
|
}
|
|
|
|
const REDACTED = '[REDACTED]'
|
|
|
|
const REDACT_KEYS = new Set([
|
|
'password',
|
|
'token',
|
|
'access_token',
|
|
'refresh_token',
|
|
'apikey',
|
|
'api_key',
|
|
'secret',
|
|
'authorization',
|
|
'cookie',
|
|
'bank_account',
|
|
'bankaccount',
|
|
'iban',
|
|
'personnummer',
|
|
'ssn',
|
|
'credentials',
|
|
])
|
|
|
|
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
|
const PERSONNUMMER_PATTERN = /\b\d{6}-?\d{4}\b|\b\d{8}-?\d{4}\b/
|
|
|
|
function redactString(value: string): string {
|
|
// Strip UUIDs first to avoid false-positive personnummer matches
|
|
const stripped = value.replace(UUID_PATTERN, '')
|
|
if (PERSONNUMMER_PATTERN.test(stripped)) {
|
|
return REDACTED
|
|
}
|
|
return value
|
|
}
|
|
|
|
function redact(value: unknown, keyPath = ''): unknown {
|
|
if (value === null || value === undefined) return value
|
|
if (typeof value === 'string') return redactString(value)
|
|
if (typeof value === 'number' || typeof value === 'boolean') return value
|
|
if (value instanceof Date) return value.toISOString()
|
|
if (value instanceof Error) {
|
|
return {
|
|
name: value.name,
|
|
message: redactString(value.message),
|
|
stack: process.env.NODE_ENV === 'production' ? undefined : value.stack,
|
|
code: (value as Error & { code?: unknown }).code,
|
|
}
|
|
}
|
|
if (Array.isArray(value)) return value.map((v, i) => redact(v, `${keyPath}[${i}]`))
|
|
if (typeof value === 'object') {
|
|
const out: Record<string, unknown> = {}
|
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
if (REDACT_KEYS.has(k.toLowerCase())) {
|
|
out[k] = REDACTED
|
|
} else {
|
|
out[k] = redact(v, keyPath ? `${keyPath}.${k}` : k)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return value
|
|
}
|
|
|
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
return (
|
|
typeof v === 'object' &&
|
|
v !== null &&
|
|
!(v instanceof Error) &&
|
|
!Array.isArray(v) &&
|
|
!(v instanceof Date) &&
|
|
Object.getPrototypeOf(v) === Object.prototype
|
|
)
|
|
}
|
|
|
|
function shouldLog(level: LogLevel): boolean {
|
|
if (process.env.NODE_ENV === 'test') return level === 'error'
|
|
return true
|
|
}
|
|
|
|
interface LogRecord {
|
|
level: LogLevel
|
|
module: string
|
|
msg: string
|
|
ts: string
|
|
err?: unknown
|
|
details?: unknown[]
|
|
[k: string]: unknown
|
|
}
|
|
|
|
function buildRecord(
|
|
level: LogLevel,
|
|
module: string,
|
|
base: LogContext,
|
|
message: string,
|
|
args: unknown[],
|
|
): LogRecord {
|
|
const ctx: Record<string, unknown> = { ...base }
|
|
let err: unknown
|
|
const details: unknown[] = []
|
|
|
|
for (const arg of args) {
|
|
if (arg instanceof Error) {
|
|
// First Error wins; subsequent ones land in details
|
|
if (err === undefined) err = redact(arg)
|
|
else details.push(redact(arg))
|
|
} else if (isPlainObject(arg)) {
|
|
Object.assign(ctx, redact(arg) as Record<string, unknown>)
|
|
} else if (arg !== undefined) {
|
|
details.push(redact(arg))
|
|
}
|
|
}
|
|
|
|
const record: LogRecord = {
|
|
level,
|
|
module,
|
|
msg: redactString(message),
|
|
ts: new Date().toISOString(),
|
|
...(redact(ctx) as Record<string, unknown>),
|
|
}
|
|
if (err !== undefined) record.err = err
|
|
if (details.length > 0) record.details = details
|
|
return record
|
|
}
|
|
|
|
function emit(record: LogRecord) {
|
|
const fn =
|
|
record.level === 'error' ? console.error : record.level === 'warn' ? console.warn : console.log
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
fn(JSON.stringify(record))
|
|
return
|
|
}
|
|
|
|
// Pretty dev output
|
|
const { level, module, msg, ts: _ts, err, details, ...ctx } = record
|
|
const ctxKeys = Object.keys(ctx)
|
|
const ctxStr = ctxKeys.length > 0 ? ' ' + ctxKeys.map((k) => `${k}=${JSON.stringify(ctx[k])}`).join(' ') : ''
|
|
const prefix = `[${module}]`
|
|
const tag = level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO'
|
|
fn(`${prefix} ${tag} ${msg}${ctxStr}`)
|
|
if (err) fn(' err:', err)
|
|
if (details && details.length > 0) fn(' details:', ...details)
|
|
}
|
|
|
|
function makeLogger(module: string, base: LogContext): Logger {
|
|
return {
|
|
info(message: string, ...args: unknown[]) {
|
|
if (!shouldLog('info')) return
|
|
emit(buildRecord('info', module, base, message, args))
|
|
},
|
|
warn(message: string, ...args: unknown[]) {
|
|
if (!shouldLog('warn')) return
|
|
emit(buildRecord('warn', module, base, message, args))
|
|
},
|
|
error(message: string, ...args: unknown[]) {
|
|
if (!shouldLog('error')) return
|
|
emit(buildRecord('error', module, base, message, args))
|
|
},
|
|
child(extra: LogContext): Logger {
|
|
return makeLogger(module, { ...base, ...extra })
|
|
},
|
|
}
|
|
}
|
|
|
|
export function createLogger(module: string, base: LogContext = {}): Logger {
|
|
return makeLogger(module, base)
|
|
}
|
|
|
|
/**
|
|
* Test-only escape hatch. Returns a logger that writes records to the supplied
|
|
* array instead of stdout. Useful for asserting on emitted log lines.
|
|
*/
|
|
export function createTestLogger(module: string, sink: LogRecord[], base: LogContext = {}): Logger {
|
|
const push = (level: LogLevel, message: string, args: unknown[]) => {
|
|
sink.push(buildRecord(level, module, base, message, args))
|
|
}
|
|
return {
|
|
info(message: string, ...args: unknown[]) {
|
|
push('info', message, args)
|
|
},
|
|
warn(message: string, ...args: unknown[]) {
|
|
push('warn', message, args)
|
|
},
|
|
error(message: string, ...args: unknown[]) {
|
|
push('error', message, args)
|
|
},
|
|
child(extra: LogContext): Logger {
|
|
return createTestLogger(module, sink, { ...base, ...extra })
|
|
},
|
|
}
|
|
}
|