plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
Showing only changes of commit c095fad7ad - Show all commits
@@ -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).