From 1a40686490945d3cadc860465cda102d335c17f3 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 23:53:13 +0300 Subject: [PATCH] Add LoggerImpl with transport dispatch and child context propagation --- src/observability/logger/logger-impl.test.ts | 81 ++++++++++++++++++++ src/observability/logger/logger-impl.ts | 46 +++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/observability/logger/logger-impl.test.ts create mode 100644 src/observability/logger/logger-impl.ts diff --git a/src/observability/logger/logger-impl.test.ts b/src/observability/logger/logger-impl.test.ts new file mode 100644 index 00000000..d2f1269f --- /dev/null +++ b/src/observability/logger/logger-impl.test.ts @@ -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(); + }); +}); diff --git a/src/observability/logger/logger-impl.ts b/src/observability/logger/logger-impl.ts new file mode 100644 index 00000000..217323bc --- /dev/null +++ b/src/observability/logger/logger-impl.ts @@ -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); + } +}