# 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).
