first commmit
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user