/** * Minimal structured logger + performance-metric emitter. * * Two responsibilities live here to keep the common layer thin: * * 1. **Leveled logging** — wraps `console.*` with a monotonically increasing * severity threshold so we can downgrade chatty modules in production. * 2. **Performance埋点** — records named samples (e.g. touch→response latency) * and computes p50/p95/avg for QA validation of requirement 20.1-20.6. * * Both halves are deliberately kept independent: the `metric()` API can be * routed to a BI endpoint later without touching the logging API. */ export enum LogLevel { Debug = 0, Info = 1, Warn = 2, Error = 3, Silent = 4, } export type LogSink = (level: LogLevel, module: string, msg: string, ...rest: unknown[]) => void; export interface MetricSample { /** Metric name (e.g. 'touch_response_ms'). */ name: string; /** Numeric value (ms, count, %, etc.). */ value: number; /** Optional tags for slicing (e.g. `{ button: 'jump' }`). */ tags?: Record; /** `realTime` timestamp, ms since app start. */ ts: number; } export interface MetricAggregate { count: number; min: number; max: number; avg: number; p50: number; p95: number; } export class Logger { private threshold: LogLevel = LogLevel.Debug; private sink: LogSink = (level, module, msg, ...rest) => { const prefix = `[${LogLevel[level]}][${module}]`; switch (level) { case LogLevel.Error: // eslint-disable-next-line no-console console.error(prefix, msg, ...rest); break; case LogLevel.Warn: // eslint-disable-next-line no-console console.warn(prefix, msg, ...rest); break; case LogLevel.Info: // eslint-disable-next-line no-console console.info(prefix, msg, ...rest); break; default: // eslint-disable-next-line no-console console.log(prefix, msg, ...rest); } }; private readonly metrics = new Map(); private readonly startedTimers = new Map(); /** Control verbosity globally (call once at boot). */ public setLevel(level: LogLevel): void { this.threshold = level; } /** Redirect logs (used in tests to assert against messages). */ public setSink(sink: LogSink): void { this.sink = sink; } public debug(mod: string, msg: string, ...rest: unknown[]): void { this.dispatch(LogLevel.Debug, mod, msg, rest); } public info(mod: string, msg: string, ...rest: unknown[]): void { this.dispatch(LogLevel.Info, mod, msg, rest); } public warn(mod: string, msg: string, ...rest: unknown[]): void { this.dispatch(LogLevel.Warn, mod, msg, rest); } public error(mod: string, msg: string, ...rest: unknown[]): void { this.dispatch(LogLevel.Error, mod, msg, rest); } // ---------- metric API ---------- /** Record a single metric sample. */ public metric(sample: Omit): void { const list = this.metrics.get(sample.name) ?? []; list.push(sample.value); this.metrics.set(sample.name, list); } /** Start a named stopwatch. */ public timerStart(name: string, now: number = Logger.now()): void { this.startedTimers.set(name, now); } /** * Stop a named stopwatch and record its elapsed time (ms) under `name`. * Returns the elapsed value or `undefined` if the timer was not started. */ public timerEnd(name: string, now: number = Logger.now()): number | undefined { const start = this.startedTimers.get(name); if (start === undefined) { return undefined; } this.startedTimers.delete(name); const elapsed = now - start; this.metric({ name, value: elapsed }); return elapsed; } /** Compute aggregate stats (used by QA dashboards and test assertions). */ public aggregate(name: string): MetricAggregate | undefined { const list = this.metrics.get(name); if (!list || list.length === 0) { return undefined; } const sorted = list.slice().sort((a, b) => a - b); const count = sorted.length; const sum = sorted.reduce((s, v) => s + v, 0); const pct = (p: number): number => { // Inclusive nearest-rank definition (matches common QA tools). const rank = Math.min(count - 1, Math.max(0, Math.ceil((p / 100) * count) - 1)); return sorted[rank]; }; return { count, min: sorted[0], max: sorted[count - 1], avg: sum / count, p50: pct(50), p95: pct(95), }; } /** Clear all recorded metrics (useful between unit tests). */ public resetMetrics(): void { this.metrics.clear(); this.startedTimers.clear(); } private dispatch(level: LogLevel, mod: string, msg: string, rest: unknown[]): void { if (level < this.threshold) { return; } this.sink(level, mod, msg, ...rest); } private static now(): number { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { return performance.now(); } return Date.now(); } } /** Shared project-wide logger. */ export const globalLogger = new Logger();