Add Phase 1G-logger runtime implementation plan
6 tasks: TDD LoggerImpl, ConsoleTransport, JsonLinesHttpTransport with batching/backpressure/redaction, createRootLogger factory with env-based transport selection, LoggerProvider React context.
This commit is contained in:
@@ -0,0 +1,791 @@
|
||||
# Phase 1G-logger — Logger Runtime Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the runtime logger — console transport (dev), JSON-lines HTTP transport (production), a `createRootLogger()` factory, and a React context provider with `useLogger()` — so that 1F-layout and all downstream features can log structured events with `logger.info("msg", { field: "value" })` in both SSR and client contexts.
|
||||
|
||||
**Architecture:** `src/observability/logger/types.ts` already exists (seeded in 1A-1 with `Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport`). This plan adds the runtime implementation: a `LoggerImpl` class that dispatches to pluggable transports, two built-in transports (console for dev, JSON-lines HTTP for production), and a factory + React provider. The logger is designed for request-scoped child loggers on the server (each request gets a child with `{ traceId, locale }` fields) and a single root logger shared on the client.
|
||||
|
||||
**Tech Stack:** No new dependencies. The JSON-lines transport uses `fetch` / `navigator.sendBeacon` (browser) or `globalThis.fetch` (Node) — both built-in on Node 24.
|
||||
|
||||
**Prerequisites:** 1A-1 (types.ts already shipped), 1A-3 (ESLint boundaries).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Task |
|
||||
|---|---|---|
|
||||
| `src/observability/logger/logger-impl.ts` | `LoggerImpl` class implementing `Logger` | 1 |
|
||||
| `src/observability/logger/logger-impl.test.ts` | Tests | 1 |
|
||||
| `src/observability/logger/console-transport.ts` | Dev-mode console transport | 2 |
|
||||
| `src/observability/logger/console-transport.test.ts` | Tests | 2 |
|
||||
| `src/observability/logger/json-lines-transport.ts` | Production HTTP transport with batching | 3 |
|
||||
| `src/observability/logger/json-lines-transport.test.ts` | Tests | 3 |
|
||||
| `src/observability/logger/root.ts` | `createRootLogger()` factory | 4 |
|
||||
| `src/observability/logger/root.test.ts` | Tests | 4 |
|
||||
| `src/observability/logger/provider.tsx` | React context + `useLogger()` | 5 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — TDD `LoggerImpl`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/logger-impl.ts`
|
||||
- Create: `src/observability/logger/logger-impl.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/logger/logger-impl.test.ts`:
|
||||
|
||||
```typescript
|
||||
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");
|
||||
logger.error("failed", { err, op: "fetch" });
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/logger/logger-impl
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/logger/logger-impl.ts`:
|
||||
|
||||
```typescript
|
||||
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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/logger/logger-impl
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/logger/logger-impl.ts src/observability/logger/logger-impl.test.ts
|
||||
git commit -m "Add LoggerImpl with transport dispatch and child context propagation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — TDD console transport
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/console-transport.ts`
|
||||
- Create: `src/observability/logger/console-transport.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/logger/console-transport.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LogRecord } from "./types.js";
|
||||
import { ConsoleTransport } from "./console-transport.js";
|
||||
|
||||
describe("ConsoleTransport", () => {
|
||||
it("pipes debug records to console.debug", () => {
|
||||
const spy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
const transport = new ConsoleTransport();
|
||||
const record: LogRecord = { ts: "2025-01-01T00:00:00Z", level: "debug", msg: "hello", fields: {} };
|
||||
transport.write(record);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy.mock.calls[0]?.[0]).toContain("hello");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("pipes info records to console.info", () => {
|
||||
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
const transport = new ConsoleTransport();
|
||||
transport.write({ ts: "2025-01-01T00:00:00Z", level: "info", msg: "info msg", fields: { key: "val" } });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy.mock.calls[0]?.[0]).toContain("info msg");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("pipes warn records to console.warn", () => {
|
||||
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const transport = new ConsoleTransport();
|
||||
transport.write({ ts: "2025-01-01T00:00:00Z", level: "warn", msg: "w", fields: {} });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("pipes error records to console.error", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const transport = new ConsoleTransport();
|
||||
transport.write({ ts: "2025-01-01T00:00:00Z", level: "error", msg: "e", fields: {} });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("flush is a no-op that resolves immediately", async () => {
|
||||
const transport = new ConsoleTransport();
|
||||
await expect(transport.flush()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/logger/console-transport.ts`:
|
||||
|
||||
```typescript
|
||||
import type { LogRecord, LogTransport } from "./types.js";
|
||||
|
||||
/**
|
||||
* Dev-mode transport that pipes log records to the browser/Node console.
|
||||
* Each record is printed as `[LEVEL] ts msg {fields}`.
|
||||
*/
|
||||
export class ConsoleTransport implements LogTransport {
|
||||
write(record: LogRecord): void {
|
||||
const prefix = `[${record.level.toUpperCase()}] ${record.ts}`;
|
||||
const hasFields = Object.keys(record.fields).length > 0;
|
||||
const msg = hasFields
|
||||
? `${prefix} ${record.msg} ${JSON.stringify(record.fields)}`
|
||||
: `${prefix} ${record.msg}`;
|
||||
|
||||
switch (record.level) {
|
||||
case "debug":
|
||||
console.debug(msg);
|
||||
break;
|
||||
case "info":
|
||||
console.info(msg);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(msg);
|
||||
break;
|
||||
case "error":
|
||||
console.error(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// Console output is synchronous — nothing to flush.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/logger/console-transport.ts src/observability/logger/console-transport.test.ts
|
||||
git commit -m "Add dev-mode ConsoleTransport for logger"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — TDD JSON-lines HTTP transport
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/json-lines-transport.ts`
|
||||
- Create: `src/observability/logger/json-lines-transport.test.ts`
|
||||
|
||||
Features: batching (collect N records or wait M ms, whichever comes first), backpressure drop (if buffer exceeds max, drop oldest), redaction of sensitive field names, `sendBeacon` on page unload / `flush()`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/logger/json-lines-transport.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { LogRecord } from "./types.js";
|
||||
import { JsonLinesHttpTransport } from "./json-lines-transport.js";
|
||||
|
||||
function record(overrides?: Partial<LogRecord>): LogRecord {
|
||||
return {
|
||||
ts: "2025-01-01T00:00:00.000Z",
|
||||
level: "info",
|
||||
msg: "test",
|
||||
fields: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("JsonLinesHttpTransport", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchSpy = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("flushes batch after batchSize records", async () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 2,
|
||||
flushIntervalMs: 60000,
|
||||
maxBufferSize: 100,
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record({ msg: "one" }));
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
transport.write(record({ msg: "two" }));
|
||||
// Should have flushed after 2nd record
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const body = fetchSpy.mock.calls[0]?.[1]?.body as string;
|
||||
const lines = body.trim().split("\n");
|
||||
expect(lines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("flushes after flushIntervalMs even if batch is not full", async () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 100,
|
||||
flushIntervalMs: 1000,
|
||||
maxBufferSize: 100,
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record());
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops oldest records when buffer exceeds maxBufferSize", () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 100,
|
||||
flushIntervalMs: 60000,
|
||||
maxBufferSize: 3,
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record({ msg: "1" }));
|
||||
transport.write(record({ msg: "2" }));
|
||||
transport.write(record({ msg: "3" }));
|
||||
transport.write(record({ msg: "4" }));
|
||||
transport.write(record({ msg: "5" }));
|
||||
|
||||
// Force flush to see what's in the buffer
|
||||
transport.flush();
|
||||
const body = fetchSpy.mock.calls[0]?.[1]?.body as string;
|
||||
const lines = body.trim().split("\n");
|
||||
expect(lines).toHaveLength(3);
|
||||
// Should contain the 3 most recent
|
||||
expect(lines[0]).toContain('"3"');
|
||||
expect(lines[1]).toContain('"4"');
|
||||
expect(lines[2]).toContain('"5"');
|
||||
});
|
||||
|
||||
it("redacts sensitive field names", async () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 1,
|
||||
flushIntervalMs: 60000,
|
||||
maxBufferSize: 100,
|
||||
redactFields: ["password", "token", "secret"],
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record({
|
||||
fields: { password: "hunter2", token: "abc123", safe: "visible" },
|
||||
}));
|
||||
|
||||
const body = fetchSpy.mock.calls[0]?.[1]?.body as string;
|
||||
const parsed = JSON.parse(body.trim());
|
||||
expect(parsed.fields.password).toBe("[REDACTED]");
|
||||
expect(parsed.fields.token).toBe("[REDACTED]");
|
||||
expect(parsed.fields.safe).toBe("visible");
|
||||
});
|
||||
|
||||
it("flush() sends all buffered records and clears the buffer", async () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 100,
|
||||
flushIntervalMs: 60000,
|
||||
maxBufferSize: 100,
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record({ msg: "a" }));
|
||||
transport.write(record({ msg: "b" }));
|
||||
await transport.flush();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Flush again — nothing to send
|
||||
await transport.flush();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/logger/json-lines-transport.ts`:
|
||||
|
||||
```typescript
|
||||
import type { LogRecord, LogTransport } from "./types.js";
|
||||
|
||||
export interface JsonLinesHttpTransportOptions {
|
||||
endpoint: string;
|
||||
batchSize?: number;
|
||||
flushIntervalMs?: number;
|
||||
maxBufferSize?: number;
|
||||
redactFields?: string[];
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export class JsonLinesHttpTransport implements LogTransport {
|
||||
private readonly endpoint: string;
|
||||
private readonly batchSize: number;
|
||||
private readonly maxBufferSize: number;
|
||||
private readonly redactFields: Set<string>;
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private buffer: LogRecord[] = [];
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(options: JsonLinesHttpTransportOptions) {
|
||||
this.endpoint = options.endpoint;
|
||||
this.batchSize = options.batchSize ?? 50;
|
||||
this.maxBufferSize = options.maxBufferSize ?? 500;
|
||||
this.redactFields = new Set(options.redactFields ?? ["password", "token", "secret", "authorization"]);
|
||||
this.fetchFn = options.fetchImpl ?? globalThis.fetch;
|
||||
|
||||
const intervalMs = options.flushIntervalMs ?? 5000;
|
||||
this.timer = setInterval(() => {
|
||||
if (this.buffer.length > 0) {
|
||||
void this.sendBatch();
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
// Unref so the timer doesn't keep the Node process alive
|
||||
if (typeof this.timer === "object" && "unref" in this.timer) {
|
||||
this.timer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
write(record: LogRecord): void {
|
||||
const redacted = this.redact(record);
|
||||
this.buffer.push(redacted);
|
||||
|
||||
// Backpressure: drop oldest if buffer exceeds max
|
||||
while (this.buffer.length > this.maxBufferSize) {
|
||||
this.buffer.shift();
|
||||
}
|
||||
|
||||
// Flush if batch is full
|
||||
if (this.buffer.length >= this.batchSize) {
|
||||
void this.sendBatch();
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.buffer.length === 0) return;
|
||||
await this.sendBatch();
|
||||
}
|
||||
|
||||
private async sendBatch(): Promise<void> {
|
||||
const batch = this.buffer.splice(0, this.buffer.length);
|
||||
if (batch.length === 0) return;
|
||||
|
||||
const body = batch.map((r) => JSON.stringify(r)).join("\n");
|
||||
|
||||
try {
|
||||
await this.fetchFn(this.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
body,
|
||||
});
|
||||
} catch {
|
||||
// Silently drop failed sends — logging a log failure causes recursion.
|
||||
// In production, a metrics counter would track send failures.
|
||||
}
|
||||
}
|
||||
|
||||
private redact(record: LogRecord): LogRecord {
|
||||
if (this.redactFields.size === 0) return record;
|
||||
|
||||
const fields = { ...record.fields };
|
||||
for (const key of Object.keys(fields)) {
|
||||
if (this.redactFields.has(key.toLowerCase())) {
|
||||
fields[key] = "[REDACTED]";
|
||||
}
|
||||
}
|
||||
return { ...record, fields };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/logger/json-lines-transport.ts src/observability/logger/json-lines-transport.test.ts
|
||||
git commit -m "Add JsonLinesHttpTransport with batching, backpressure, and redaction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — TDD `createRootLogger()` factory
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/root.ts`
|
||||
- Create: `src/observability/logger/root.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/logger/root.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import type { Logger } from "./types.js";
|
||||
|
||||
describe("createRootLogger", () => {
|
||||
afterEach(async () => {
|
||||
const mod = await import("./root.js");
|
||||
mod.__resetRootLoggerForTests();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns a Logger with console transport in development", async () => {
|
||||
process.env["NODE_ENV"] = "development";
|
||||
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
|
||||
__resetRootLoggerForTests();
|
||||
const logger = createRootLogger();
|
||||
expect(logger).toBeDefined();
|
||||
expect(typeof logger.info).toBe("function");
|
||||
expect(typeof logger.child).toBe("function");
|
||||
});
|
||||
|
||||
it("returns a Logger with JSON-lines transport in production", async () => {
|
||||
process.env["NODE_ENV"] = "production";
|
||||
process.env["LOGS_ENDPOINT"] = "https://logs.example/ingest";
|
||||
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
|
||||
__resetRootLoggerForTests();
|
||||
const logger = createRootLogger();
|
||||
expect(logger).toBeDefined();
|
||||
expect(typeof logger.info).toBe("function");
|
||||
});
|
||||
|
||||
it("caches the logger instance (returns same object on repeated calls)", async () => {
|
||||
process.env["NODE_ENV"] = "development";
|
||||
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
|
||||
__resetRootLoggerForTests();
|
||||
const a = createRootLogger();
|
||||
const b = createRootLogger();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("child() produces a Logger with merged context", async () => {
|
||||
process.env["NODE_ENV"] = "development";
|
||||
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
|
||||
__resetRootLoggerForTests();
|
||||
const logger = createRootLogger();
|
||||
const child = logger.child({ traceId: "test-123" });
|
||||
child.info("hello");
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy.mock.calls[0]?.[0]).toContain("test-123");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/logger/root.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Logger, LogTransport } from "./types.js";
|
||||
import { LoggerImpl } from "./logger-impl.js";
|
||||
import { ConsoleTransport } from "./console-transport.js";
|
||||
import { JsonLinesHttpTransport } from "./json-lines-transport.js";
|
||||
|
||||
let cached: Logger | undefined;
|
||||
|
||||
/**
|
||||
* Creates or returns the cached root logger. In development, uses
|
||||
* ConsoleTransport. In other envs, uses JsonLinesHttpTransport if
|
||||
* LOGS_ENDPOINT is set, otherwise falls back to console.
|
||||
*/
|
||||
export function createRootLogger(): Logger {
|
||||
if (cached) return cached;
|
||||
|
||||
const env = process.env["NODE_ENV"] ?? "development";
|
||||
const logsEndpoint = process.env["LOGS_ENDPOINT"];
|
||||
|
||||
let transport: LogTransport;
|
||||
|
||||
if (env === "development" || !logsEndpoint) {
|
||||
transport = new ConsoleTransport();
|
||||
} else {
|
||||
transport = new JsonLinesHttpTransport({
|
||||
endpoint: logsEndpoint,
|
||||
batchSize: 50,
|
||||
flushIntervalMs: 5000,
|
||||
maxBufferSize: 500,
|
||||
});
|
||||
}
|
||||
|
||||
cached = new LoggerImpl(transport);
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Test-only: clears the cached root logger. */
|
||||
export function __resetRootLoggerForTests(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/logger/root.ts src/observability/logger/root.test.ts
|
||||
git commit -m "Add createRootLogger factory with transport selection by env"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Create `src/observability/logger/provider.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/provider.tsx`
|
||||
|
||||
No TDD — thin React wrapper, exercised by 1F-layout.
|
||||
|
||||
- [ ] **Step 1: Write `src/observability/logger/provider.tsx`**
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Logger } from "./types.js";
|
||||
|
||||
const LoggerContext = createContext<Logger | null>(null);
|
||||
|
||||
export interface LoggerProviderProps {
|
||||
logger: Logger;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the Logger instance to the React tree. On the server,
|
||||
* use a request-scoped child logger (with traceId, locale). On the
|
||||
* client, use the shared root logger from createRootLogger().
|
||||
*/
|
||||
export function LoggerProvider({
|
||||
logger,
|
||||
children,
|
||||
}: LoggerProviderProps): JSX.Element {
|
||||
return (
|
||||
<LoggerContext.Provider value={logger}>
|
||||
{children}
|
||||
</LoggerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Logger from context. Throws if used outside
|
||||
* <LoggerProvider>.
|
||||
*/
|
||||
export function useLogger(): Logger {
|
||||
const logger = useContext(LoggerContext);
|
||||
if (!logger) {
|
||||
throw new Error(
|
||||
"useLogger() must be used within a <LoggerProvider>. " +
|
||||
"Ensure the root layout wraps the tree with <LoggerProvider>.",
|
||||
);
|
||||
}
|
||||
return logger;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/observability/logger/provider.tsx
|
||||
git commit -m "Add LoggerProvider React context with useLogger hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — Exit-gate verification
|
||||
|
||||
- [ ] **Step 1: All gates**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
Expected: all pass. Test count: 83 (prior) + LoggerImpl + ConsoleTransport + JsonLines + RootLogger = ~100+ total.
|
||||
|
||||
- [ ] **Step 2: Git status clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Master plan §1G-logger:
|
||||
- types.ts (already shipped in 1A-1) ✓
|
||||
- `JsonLinesHttpTransport` with batching, backpressure, redaction, flush → Task 3
|
||||
- `ConsoleTransport` for dev → Task 2
|
||||
- `createRootLogger()` factory → Task 4
|
||||
- React context + `useLogger()` → Task 5
|
||||
- A4-trigger task → documented in master plan, not implemented here (fires on A4 resolution)
|
||||
- Exit gate tests: batching+flush, redaction, backpressure, console transport, child() → Tasks 1-4
|
||||
|
||||
**Type consistency.** `Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport` all from `./types.js` (seeded in 1A-1).
|
||||
Reference in New Issue
Block a user