171 lines
5.5 KiB
TypeScript
171 lines
5.5 KiB
TypeScript
/**
|
|
* 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<string, string | number>;
|
|
/** `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<string, number[]>();
|
|
private readonly startedTimers = new Map<string, number>();
|
|
|
|
/** 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<MetricSample, 'ts'>): 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();
|