From c095fad7ad8dccc429478e1cae78d683227d4c4a Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 23:51:04 +0300 Subject: [PATCH] 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. --- .../plans/2026-04-14-phase-1g-logger.md | 791 ++++++++++++++++++ 1 file changed, 791 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-phase-1g-logger.md diff --git a/docs/superpowers/plans/2026-04-14-phase-1g-logger.md b/docs/superpowers/plans/2026-04-14-phase-1g-logger.md new file mode 100644 index 00000000..d83ce220 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1g-logger.md @@ -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 { + // 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 { + return { + ts: "2025-01-01T00:00:00.000Z", + level: "info", + msg: "test", + fields: {}, + ...overrides, + }; +} + +describe("JsonLinesHttpTransport", () => { + let fetchSpy: ReturnType; + + 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; + private readonly fetchFn: typeof fetch; + private buffer: LogRecord[] = []; + private timer: ReturnType | 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 { + if (this.buffer.length === 0) return; + await this.sendBatch(); + } + + private async sendBatch(): Promise { + 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(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 ( + + {children} + + ); +} + +/** + * Returns the Logger from context. Throws if used outside + * . + */ +export function useLogger(): Logger { + const logger = useContext(LoggerContext); + if (!logger) { + throw new Error( + "useLogger() must be used within a . " + + "Ensure the root layout wraps the tree with .", + ); + } + 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).