Files
KateLegend2_proj/assets/scripts/common/Logger.ts
T
2026-05-06 08:17:32 +08:00

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();