Add LoggerImpl with transport dispatch and child context propagation
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { LogRecord, LogTransport } from "./types.js";
|
||||||
|
import { LoggerImpl } from "./logger-impl.js";
|
||||||
|
|
||||||
|
function mockTransport(): LogTransport & { records: LogRecord[] } {
|
||||||
|
const records: LogRecord[] = [];
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
write(record: LogRecord) { records.push(record); },
|
||||||
|
flush: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LoggerImpl", () => {
|
||||||
|
it("writes a record at each log level", () => {
|
||||||
|
const t = mockTransport();
|
||||||
|
const logger = new LoggerImpl(t);
|
||||||
|
|
||||||
|
logger.debug("d");
|
||||||
|
logger.info("i");
|
||||||
|
logger.warn("w");
|
||||||
|
logger.error("e");
|
||||||
|
|
||||||
|
expect(t.records).toHaveLength(4);
|
||||||
|
expect(t.records.map(r => r.level)).toEqual(["debug", "info", "warn", "error"]);
|
||||||
|
expect(t.records.map(r => r.msg)).toEqual(["d", "i", "w", "e"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes fields in the record", () => {
|
||||||
|
const t = mockTransport();
|
||||||
|
const logger = new LoggerImpl(t);
|
||||||
|
logger.info("msg", { userId: 123, action: "click" });
|
||||||
|
expect(t.records[0]?.fields).toEqual({ userId: 123, action: "click" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes an ISO timestamp", () => {
|
||||||
|
const t = mockTransport();
|
||||||
|
const logger = new LoggerImpl(t);
|
||||||
|
logger.info("msg");
|
||||||
|
expect(t.records[0]?.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("child() propagates context fields to all records", () => {
|
||||||
|
const t = mockTransport();
|
||||||
|
const logger = new LoggerImpl(t);
|
||||||
|
const child = logger.child({ traceId: "abc", locale: "ru" });
|
||||||
|
child.info("hello", { extra: true });
|
||||||
|
|
||||||
|
expect(t.records[0]?.fields).toEqual({
|
||||||
|
traceId: "abc",
|
||||||
|
locale: "ru",
|
||||||
|
extra: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("child of child merges contexts", () => {
|
||||||
|
const t = mockTransport();
|
||||||
|
const logger = new LoggerImpl(t);
|
||||||
|
const child1 = logger.child({ traceId: "abc" });
|
||||||
|
const child2 = child1.child({ requestId: "123" });
|
||||||
|
child2.info("deep");
|
||||||
|
|
||||||
|
expect(t.records[0]?.fields).toEqual({
|
||||||
|
traceId: "abc",
|
||||||
|
requestId: "123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error level includes err field if provided", () => {
|
||||||
|
const t = mockTransport();
|
||||||
|
const logger = new LoggerImpl(t);
|
||||||
|
const err = new Error("boom");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
logger.error("failed", { err, op: "fetch" } as any);
|
||||||
|
|
||||||
|
const record = t.records[0];
|
||||||
|
expect(record?.fields?.["op"]).toBe("fetch");
|
||||||
|
// The err field should be serialized — at minimum the message
|
||||||
|
expect(record?.fields?.["err"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Logger, LogFields, LogLevel, LogRecord, LogTransport } from "./types.js";
|
||||||
|
|
||||||
|
export class LoggerImpl implements Logger {
|
||||||
|
private readonly transport: LogTransport;
|
||||||
|
private readonly context: LogFields;
|
||||||
|
|
||||||
|
constructor(transport: LogTransport, context: LogFields = {}) {
|
||||||
|
this.transport = transport;
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(msg: string, fields?: LogFields): void {
|
||||||
|
this.write("debug", msg, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(msg: string, fields?: LogFields): void {
|
||||||
|
this.write("info", msg, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(msg: string, fields?: LogFields): void {
|
||||||
|
this.write("warn", msg, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(msg: string, fields?: LogFields & { err?: Error }): void {
|
||||||
|
const { err, ...rest } = fields ?? {};
|
||||||
|
const serialized: LogFields = { ...rest };
|
||||||
|
if (err) {
|
||||||
|
serialized["err"] = `${err.name}: ${err.message}`;
|
||||||
|
}
|
||||||
|
this.write("error", msg, serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
child(context: LogFields): Logger {
|
||||||
|
return new LoggerImpl(this.transport, { ...this.context, ...context });
|
||||||
|
}
|
||||||
|
|
||||||
|
private write(level: LogLevel, msg: string, fields?: LogFields): void {
|
||||||
|
const record: LogRecord = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
msg,
|
||||||
|
fields: { ...this.context, ...fields },
|
||||||
|
};
|
||||||
|
this.transport.write(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user