first commit
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
# `batching`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Buffers outbound log records and flushes them in batches over a single
|
||||
MessagePort frame. Reducing one-frame-per-record to one-frame-per-batch
|
||||
materially lowers the wire cost for workers under load, where a sustained
|
||||
plugin can emit several hundred records per second. The batcher is
|
||||
sink-agnostic — it invokes an `onFlush` callback supplied at construction
|
||||
with a frozen snapshot of buffered records, so it composes equally with
|
||||
the worker-side client's `postMessage` call and with test sinks.
|
||||
|
||||
A second concern handled here is backpressure. Logging is best-effort: a
|
||||
slow main-side sink must never break the application by surfacing as an
|
||||
exception in `log.info(...)`. The batcher caps the number of
|
||||
outstanding-but-not-yet-released batches at `maxOutstandingBatches`; once
|
||||
the cap is reached new records are dropped silently and an `onDrop`
|
||||
callback fires so the consumer can increment the
|
||||
`LOGGER_PROXY_BACKPRESSURE_DROP` counter.
|
||||
|
||||
## Features
|
||||
|
||||
- Flush triggers: max batch size (default 100), idle interval (default
|
||||
50 ms), `error`/`fatal` level (immediate, but records buffered before
|
||||
the error still ship in the same batch — order is preserved).
|
||||
- Bounded outstanding-batch budget (default 8). Dropped records increment
|
||||
a counter; the batcher never throws on `enqueue`.
|
||||
- Injectable `TimeoutScheduler` for deterministic timer tests.
|
||||
- `dispose()` clears the timer and drops anything still buffered.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LogBatcher } from "@suaveplan/api-logger-worker-proxy/batching";
|
||||
|
||||
const batcher = new LogBatcher((records) => {
|
||||
port.postMessage({
|
||||
v: "1.0.0",
|
||||
kind: "log-batch",
|
||||
plugin: { name: "demo", version: "0.1.0" },
|
||||
records,
|
||||
});
|
||||
batcher.releaseBatch();
|
||||
});
|
||||
|
||||
batcher.enqueue({
|
||||
level: "info",
|
||||
msg: "hello",
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wiring the drop counter to an OTel meter and using a deterministic
|
||||
scheduler in tests:
|
||||
|
||||
```ts
|
||||
import {
|
||||
LogBatcher,
|
||||
type TimeoutScheduler,
|
||||
} from "@suaveplan/api-logger-worker-proxy/batching";
|
||||
|
||||
let dropped = 0;
|
||||
const fakeScheduler: TimeoutScheduler = {
|
||||
set: (h) => {
|
||||
queueMicrotask(h);
|
||||
return null;
|
||||
},
|
||||
clear: () => {},
|
||||
};
|
||||
|
||||
const batcher = new LogBatcher((records) => sink.write(records), {
|
||||
maxBatchSize: 10,
|
||||
flushIntervalMs: 5,
|
||||
maxOutstandingBatches: 4,
|
||||
onDrop: () => {
|
||||
dropped++;
|
||||
},
|
||||
scheduler: fakeScheduler,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new LogBatcher(onFlush, options?)` — constructor.
|
||||
- `enqueue(record): boolean` — `true` accepted, `false` dropped.
|
||||
- `flush()` — flush whatever is buffered; idempotent.
|
||||
- `releaseBatch()` — return a batch slot to the budget.
|
||||
- `dispose()` — stop accepting; drop everything in the buffer.
|
||||
- `droppedCount` / `flushedBatchCount` / `bufferedCount` /
|
||||
`outstandingBatches` — read-only counters for tests + telemetry.
|
||||
- `DEFAULT_BATCHER_OPTIONS` — the defaults exposed for re-use.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`error` and `fatal` flush immediately AFTER being appended to the buffer
|
||||
so any records queued before them ship in the same batch and the
|
||||
ordering is preserved. The `outstanding` counter is decremented by
|
||||
`releaseBatch()`; the worker-side client invokes that synchronously
|
||||
after `postMessage` returns since `MessagePort` has no ack frame for
|
||||
log batches and there is no transport-level confirmation to wait for.
|
||||
Best-effort posture: every code path that could otherwise throw on a
|
||||
log call (full buffer, disposed batcher) routes through `recordDrop`
|
||||
instead.
|
||||
@@ -0,0 +1,95 @@
|
||||
# `client` (worker-side)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The worker-side proxy. Exposes a `LoggerLike` surface (`debug` / `info`
|
||||
/ `warn` / `error`) that worker-isolated API plugins call as if they
|
||||
held a direct reference to `@suaveplan/logger`. Under the hood each
|
||||
call enqueues a record on a `LogBatcher`, which flushes batches over a
|
||||
`MessagePort` to the main-side server. The client is intentionally
|
||||
narrow: it does no formatting, no filtering, no sampling — those
|
||||
concerns belong to the main-side logger, which already owns the
|
||||
configured policy.
|
||||
|
||||
The client also mounts a `ControlReceiver` on the same port. When the
|
||||
main host calls `disable()` on its sender (during plugin shutdown), the
|
||||
worker's level methods become no-ops on the next call. This avoids a
|
||||
zombie-sink failure mode where the worker keeps producing records
|
||||
after the main logger has been disposed.
|
||||
|
||||
## Features
|
||||
|
||||
- `LoggerLike`-conformant surface (debug/info/warn/error).
|
||||
- Plugin pre-tagging via the `PluginTag` passed at construction.
|
||||
- Batched send (size / idle / immediate-error triggers).
|
||||
- Bounded backpressure: records drop silently and an optional
|
||||
`onDrop` callback fires; logging calls never throw.
|
||||
- Control-channel receiver wired on the same port.
|
||||
- Optional `ClientClock` (testable timestamps) and `TraceProvider`
|
||||
(OTel correlation) seams.
|
||||
- `dispose()` flushes the buffer, sends a `goodbye` frame, and
|
||||
releases the control receiver. Idempotent.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createLoggerProxyClient } from "@suaveplan/api-logger-worker-proxy/worker-side";
|
||||
|
||||
const log = createLoggerProxyClient(workerPort, {
|
||||
name: "my-plugin",
|
||||
version: "0.1.0",
|
||||
});
|
||||
log.info("request handled", { route: "/health", status: 200 });
|
||||
log.error("upstream failed", { code: "ECONNRESET" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire an OTel trace provider and a drop counter:
|
||||
|
||||
```ts
|
||||
import { createLoggerProxyClient } from "@suaveplan/api-logger-worker-proxy/worker-side";
|
||||
import { context as otelContext, trace } from "@opentelemetry/api";
|
||||
|
||||
const meter = getMeter();
|
||||
const droppedCounter = meter.createCounter("logger_proxy.dropped");
|
||||
|
||||
const log = createLoggerProxyClient(
|
||||
workerPort,
|
||||
{ name: "my-plugin", version: "0.1.0" },
|
||||
{
|
||||
trace: () => {
|
||||
const span = trace.getSpan(otelContext.active());
|
||||
if (span === undefined) return undefined;
|
||||
const ctx = span.spanContext();
|
||||
return { trace_id: ctx.traceId, span_id: ctx.spanId };
|
||||
},
|
||||
onDrop: () => droppedCounter.add(1),
|
||||
batching: { maxBatchSize: 50, flushIntervalMs: 25 },
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createLoggerProxyClient(port, plugin, options?): LoggerProxyClient`
|
||||
— primary entry point.
|
||||
- `LoggerProxyClient extends LoggerLike` — adds `flush()`,
|
||||
`droppedCount`, `enabled`, and `dispose()`.
|
||||
- `ClientClock` — `{ nowIso(): string }`; default uses `Date`.
|
||||
- `TraceProvider` — `() => TraceFields | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`enqueue` short-circuits when the control state is `disabled` — no
|
||||
record is created, no allocations, no batch traffic. Once `dispose()`
|
||||
runs, the disposed flag flips and the next log call throws
|
||||
`LOGGER_PROXY.PROXY_DISPOSED`. This is the one path where the client
|
||||
throws because use-after-dispose is a programming error and silently
|
||||
dropping would hide the bug. Goodbye is best-effort: if the port is
|
||||
already closed the throw is swallowed (port closure mid-dispose is a
|
||||
normal shutdown race).
|
||||
@@ -0,0 +1,83 @@
|
||||
# `control-channel`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bidirectional control plane: the main process tells a worker-isolated
|
||||
plugin to stop or resume emitting log records. The host calls `disable`
|
||||
during plugin shutdown so a worker that is still draining its work
|
||||
queue does not keep producing records after the main-side sink has been
|
||||
torn down (design doc §5.5 — components dispose in reverse dependency
|
||||
order). Without this channel the main-side sink would either log to a
|
||||
zombie sink (silent loss with no diagnostic) or panic on a closed port.
|
||||
|
||||
Frames are simple: `{ kind: "control", action: "disable" | "enable" }`.
|
||||
There is no acknowledgement frame; the channel is fire-and-forget. The
|
||||
worker maintains a shared `ControlState` object the proxy client checks
|
||||
synchronously on every log call, so a `disable` received between two
|
||||
calls correctly suppresses the second.
|
||||
|
||||
## Features
|
||||
|
||||
- `createControlChannelSender(port)` — returns `{ disable, enable }`
|
||||
thunks usable from the main process.
|
||||
- `mountControlChannelReceiver(port, options?)` — installs a listener
|
||||
on the worker-side port; returns the shared `ControlState` plus a
|
||||
`dispose` thunk. Tolerates malformed frames (silent drop) so a
|
||||
hostile or buggy host cannot crash the worker.
|
||||
- `onChange` callback fires only on real transitions, not on repeated
|
||||
same-value frames.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// Main side
|
||||
import { createControlChannelSender } from "@suaveplan/api-logger-worker-proxy/control-channel";
|
||||
const ctl = createControlChannelSender(mainPort);
|
||||
ctl.disable();
|
||||
// ... main-side sink torn down ...
|
||||
worker.terminate();
|
||||
```
|
||||
|
||||
```ts
|
||||
// Worker side
|
||||
import { mountControlChannelReceiver } from "@suaveplan/api-logger-worker-proxy/control-channel";
|
||||
const ctl = mountControlChannelReceiver(workerPort);
|
||||
// log.info() etc. read ctl.state.enabled synchronously
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Observe transitions to flush in-flight batches and surface a counter:
|
||||
|
||||
```ts
|
||||
import { mountControlChannelReceiver } from "@suaveplan/api-logger-worker-proxy/control-channel";
|
||||
|
||||
const ctl = mountControlChannelReceiver(port, {
|
||||
onChange: (enabled) => {
|
||||
if (!enabled) batcher.flush();
|
||||
meter.add(`logger_proxy.control_state.${enabled ? "on" : "off"}`, 1);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createControlChannelSender(port: PortLike): ControlChannelSender`
|
||||
— emits `{ v, kind: "control", action }` frames.
|
||||
- `mountControlChannelReceiver(port, options?)` —
|
||||
returns `{ state: Readonly<ControlState>, dispose }`.
|
||||
- `ControlState` — `{ enabled: boolean }`. Defaults to `true` so
|
||||
workers emit normally until told otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The receiver shares the port with `goodbye` and (incidentally) with the
|
||||
worker-side log emit path — frames whose `kind` is not `"control"` are
|
||||
ignored so the receiver composes cleanly with other listeners on the
|
||||
same port. The state object is mutated in place (not replaced) so the
|
||||
proxy client can capture a stable reference at construction time and
|
||||
not need to re-resolve a getter on the per-call hot path.
|
||||
@@ -0,0 +1,92 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable identifier registry for failures originating in
|
||||
`@suaveplan/api-logger-worker-proxy`. Every public failure path that
|
||||
results in a thrown `LoggerProxyError` carries one of these
|
||||
`LOGGER_PROXY.*` namespaced codes so consumers can branch on the literal
|
||||
value without depending on the concrete error class.
|
||||
|
||||
The package follows a best-effort posture by design: a logger that throws
|
||||
on log calls would force application code to wrap every `log.info(...)`
|
||||
in a try/catch. Instead, the proxy drops records and increments a
|
||||
counter when the wire is congested. Codes here are reserved for the
|
||||
narrow set of failures where dropping is not an option — invalid frame
|
||||
received on the main side (must surface to surface a protocol bug), or a
|
||||
caller using the worker-side proxy after `dispose()` (a programming
|
||||
error, not a transient transport problem).
|
||||
|
||||
## Features
|
||||
|
||||
- `LOGGER_PROXY_CODES` — readonly record produced by
|
||||
`defineCodes("LOGGER_PROXY", [...])`. Each value is the
|
||||
fully-qualified string (e.g. `"LOGGER_PROXY.QUEUE_OVERFLOW"`).
|
||||
- `LoggerProxyCode` — union of every fully-qualified code in the
|
||||
registry; safe to use in switch exhaustiveness checks.
|
||||
- `LoggerProxyError` — typed `SuaveplanError` subclass; carries a code
|
||||
and the standard cause chain, redaction, and OTel hooks from
|
||||
`@suaveplan/error`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
LOGGER_PROXY_CODES,
|
||||
LoggerProxyError,
|
||||
} from "@suaveplan/api-logger-worker-proxy/error-codes";
|
||||
|
||||
throw new LoggerProxyError(
|
||||
LOGGER_PROXY_CODES.PROXY_DISPOSED,
|
||||
"log.info called after dispose()"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Branching on the code rather than `instanceof` lets caller code stay
|
||||
loose-coupled to this package:
|
||||
|
||||
```ts
|
||||
import { LOGGER_PROXY_CODES } from "@suaveplan/api-logger-worker-proxy/error-codes";
|
||||
|
||||
try {
|
||||
proxy.info("hello");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
err.code === LOGGER_PROXY_CODES.PROXY_DISPOSED
|
||||
) {
|
||||
// restart the proxy
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LOGGER_PROXY_CODES.QUEUE_OVERFLOW` — emitted main-side when an
|
||||
inbound batch exceeds the configured ceiling; the inbound port is
|
||||
closed to protect the main logger.
|
||||
- `LOGGER_PROXY_CODES.PROXY_DISPOSED` — emitted worker-side when a log
|
||||
call is made after `dispose()`.
|
||||
- `LOGGER_PROXY_CODES.INVALID_FRAME` — emitted main-side when a frame
|
||||
fails Zod validation.
|
||||
- `LOGGER_PROXY_CODES.PORT_CLOSED` — emitted main-side when a `goodbye`
|
||||
frame is received or when `postMessage` throws.
|
||||
- `LOGGER_PROXY_CODES.DISABLED` — informational; the proxy is
|
||||
currently in `disable` mode and is dropping records.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` from `@suaveplan/error` produces a frozen record with
|
||||
fully-qualified string values. The package SHOULD NOT add codes for
|
||||
transient transport failures — those increment counters instead so the
|
||||
application keeps running. Adding a new code is a breaking change for
|
||||
downstream consumers that branch on the union; gate via OpenSpec.
|
||||
@@ -0,0 +1,101 @@
|
||||
# `protocol`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod-validated wire format for log frames travelling from a
|
||||
worker-isolated plugin to the main-process `@suaveplan/logger`. Frames
|
||||
are a small discriminated union — `log-batch`, `goodbye`, and `control`
|
||||
— so receivers branch on `kind` and reject malformed input cleanly via
|
||||
Zod parsing rather than ad-hoc shape checks.
|
||||
|
||||
The protocol is bidirectional: workers send `log-batch` and `goodbye`
|
||||
frames to the main, the main sends `control` frames (`disable` /
|
||||
`enable`) back to the worker so it can stop emitting after the
|
||||
main-side logger has been disposed (design doc §5.5 dispose-in-reverse).
|
||||
|
||||
## Features
|
||||
|
||||
- `LOGGER_PROXY_PROTOCOL_VERSION` — `"1.0.0"`. Embedded in every frame
|
||||
via the `v` field; servers reject on mismatch.
|
||||
- `LOG_LEVELS` / `LogLevel` — `trace | debug | info | warn | error |
|
||||
fatal`. Wider than `LoggerLike` to allow forward-compatibility; the
|
||||
main-side server narrows down before dispatch.
|
||||
- `PluginTag` / `pluginTagSchema` — `{ name, version }` attached to
|
||||
every batch.
|
||||
- `WireLogRecord` / `logRecordSchema` — single record: `level`, `msg`,
|
||||
`ts`, optional `ctx` map, optional `trace` (`trace_id` / `span_id`).
|
||||
- `LogBatchFrame` / `logBatchFrameSchema` — `{ kind: "log-batch", v,
|
||||
plugin, records: WireLogRecord[] }` with `min(1)` records.
|
||||
- `ControlFrame` / `controlFrameSchema` — `{ kind: "control", v,
|
||||
action: "disable" | "enable" }`.
|
||||
- `parseLoggerProxyFrame(input)` — validates and returns the
|
||||
discriminated union; throws `ZodError` on malformed input.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
LOGGER_PROXY_PROTOCOL_VERSION,
|
||||
parseLoggerProxyFrame,
|
||||
} from "@suaveplan/api-logger-worker-proxy/protocol";
|
||||
|
||||
const frame = parseLoggerProxyFrame({
|
||||
v: LOGGER_PROXY_PROTOCOL_VERSION,
|
||||
kind: "log-batch",
|
||||
plugin: { name: "demo", version: "0.1.0" },
|
||||
records: [
|
||||
{
|
||||
level: "info",
|
||||
msg: "hello",
|
||||
ts: new Date().toISOString(),
|
||||
ctx: { userId: 42 },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Switch over `kind` with exhaustive narrowing:
|
||||
|
||||
```ts
|
||||
import { parseLoggerProxyFrame } from "@suaveplan/api-logger-worker-proxy/protocol";
|
||||
|
||||
function handle(input: unknown): void {
|
||||
const frame = parseLoggerProxyFrame(input);
|
||||
switch (frame.kind) {
|
||||
case "log-batch":
|
||||
for (const r of frame.records) dispatch(r, frame.plugin);
|
||||
break;
|
||||
case "control":
|
||||
applyControl(frame.action);
|
||||
break;
|
||||
case "goodbye":
|
||||
shutdown(frame.reason);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LOG_LEVELS: readonly LogLevel[]` — exported tuple for enumeration.
|
||||
- `logLevelSchema: ZodEnum<LogLevel>` — re-use in downstream schemas.
|
||||
- `logRecordSchema`, `logBatchFrameSchema`, `controlFrameSchema`,
|
||||
`logGoodbyeFrameSchema`, `loggerProxyFrameSchema` — Zod schemas.
|
||||
- `parseLoggerProxyFrame(input: unknown): LoggerProxyFrame` — entry
|
||||
point for receivers. Throws on malformed input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema accepts `ctx` as `Record<string, unknown>` to keep the
|
||||
proxy transparent to the application. Main-side redaction normalises
|
||||
sensitive fields before they reach the sink. `ts` is required so the
|
||||
main-side stays a pure forwarder; the worker stamps wall-clock time at
|
||||
record creation. Frames flow over `MessagePort` and survive
|
||||
structured-clone — keeping records as plain data (no `Date`, no
|
||||
classes) is intentional.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `redaction-passthrough`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Lets the main-side server apply the host logger's redaction policy to
|
||||
records arriving from worker-isolated plugins. Workers run in their own
|
||||
runtime — they cannot reach the main process's redaction configuration —
|
||||
so the only safe place to redact is at the boundary, where the
|
||||
main-side server holds the policy and can normalise records before they
|
||||
hit the sink. This module is the seam where that happens.
|
||||
|
||||
The module also ships a small `maskKeys` helper that produces a redactor
|
||||
for the common case (mask a fixed list of top-level keys with a
|
||||
replacement string). Callers that need richer policy — nested matching,
|
||||
prefix patterns, length-preserving masks — wire `@suaveplan/logger`'s
|
||||
redactor through the `Redactor` structural type.
|
||||
|
||||
## Features
|
||||
|
||||
- `applyRedactionPassthrough(record, opts)` — pure function returning a
|
||||
new {@link WireLogRecord} with `ctx` and/or `msg` rewritten.
|
||||
- `Redactor<T>(value: T): T` — structural type matching
|
||||
`@suaveplan/logger`'s redactor surface; passes any record-shaped
|
||||
value through the policy.
|
||||
- `maskKeys(keys, replacement?)` — built-in fixed-key redactor.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
applyRedactionPassthrough,
|
||||
maskKeys,
|
||||
} from "@suaveplan/api-logger-worker-proxy/redaction-passthrough";
|
||||
|
||||
const redact = maskKeys(["password", "token"]);
|
||||
const safe = applyRedactionPassthrough(record, { ctxRedactor: redact });
|
||||
mainLogger.info(safe.msg, safe.ctx);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Combine with a message-level scrubber for free-form text:
|
||||
|
||||
```ts
|
||||
import { applyRedactionPassthrough } from "@suaveplan/api-logger-worker-proxy/redaction-passthrough";
|
||||
|
||||
const safe = applyRedactionPassthrough(record, {
|
||||
ctxRedactor: hostLoggerRedactor, // from @suaveplan/logger
|
||||
msgRedactor: (msg) => msg.replace(/\b\d{16}\b/g, "[CARD]"),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyRedactionPassthrough(record, options)` — returns a redacted
|
||||
clone. If `record.ctx` is undefined the ctx redactor is a no-op for
|
||||
that record. If no redactors are configured the original record is
|
||||
returned by reference (zero allocations).
|
||||
- `maskKeys(keys: ReadonlyArray<string>, replacement = "[REDACTED]")` —
|
||||
builds a `Redactor`. Operates only on plain-object top-level keys.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The pass-through is intentionally narrow: it does not walk arrays or
|
||||
nested objects. `@suaveplan/logger`'s full redactor handles those cases
|
||||
and is what production callers should plug in. `maskKeys` is the
|
||||
zero-config fallback so this package stays useful on its own (e.g. in
|
||||
tests, or in callers who don't take a runtime dep on the logger's
|
||||
redactor module). Input records are never mutated; a clone is produced
|
||||
when redaction actually runs.
|
||||
@@ -0,0 +1,90 @@
|
||||
# `server` (main-side)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The main-side server. Consumes inbound `log-batch` frames from a
|
||||
worker-isolated plugin, applies redaction at the boundary, narrows the
|
||||
wire-format levels (`trace` / `fatal`) down to the `LoggerLike` levels
|
||||
(`debug` / `error`), and forwards each record to the host's main-side
|
||||
`@suaveplan/logger` instance. Plugin pre-tagging is honoured by routing
|
||||
through `logger.child(plugin)` when available, so every record reaches
|
||||
the sink with structured `plugin` / `plugin_version` fields without
|
||||
the worker having to know about the host logger's child-binding
|
||||
semantics.
|
||||
|
||||
The server is best-effort, just like the worker-side client. Malformed
|
||||
frames increment a counter, fire an `onInvalidFrame` hook, and surface
|
||||
via `onError` — but they NEVER throw out of the listener. A single bad
|
||||
record cannot bring down the port.
|
||||
|
||||
## Features
|
||||
|
||||
- Wire-frame validation via Zod (`parseLoggerProxyFrame`).
|
||||
- Level narrowing (`trace → debug`, `fatal → error`) so downstream
|
||||
sinks need not understand the broader level set.
|
||||
- Optional redaction passthrough at the boundary.
|
||||
- Child-logger caching per `PluginTag` (Map keyed by
|
||||
`name@version` so caching survives structured-clone of the tag
|
||||
object across the port boundary).
|
||||
- Hard `maxBatchSize` cap (default 10_000 records/batch) that protects
|
||||
the main logger from a hostile or buggy worker dumping unbounded
|
||||
inbound traffic.
|
||||
- Observation hooks: `onRecord` (post-redaction), `onError`, and
|
||||
`onInvalidFrame`.
|
||||
- `forwardedCount`, `invalidFrameCount`, `overflowDropCount` counters.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createLoggerProxyServer } from "@suaveplan/api-logger-worker-proxy/main-side";
|
||||
import { createLogger } from "@suaveplan/logger";
|
||||
|
||||
const mainLogger = createLogger({ level: "info" });
|
||||
const server = createLoggerProxyServer(mainPort, mainLogger);
|
||||
// ... worker emits log records over workerPort ...
|
||||
server.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pipe through host redaction and observe every record:
|
||||
|
||||
```ts
|
||||
import { createLoggerProxyServer } from "@suaveplan/api-logger-worker-proxy/main-side";
|
||||
import { maskKeys } from "@suaveplan/api-logger-worker-proxy/redaction-passthrough";
|
||||
|
||||
const server = createLoggerProxyServer(mainPort, mainLogger, {
|
||||
redaction: {
|
||||
ctxRedactor: maskKeys(["password", "token", "secret"]),
|
||||
},
|
||||
onRecord: (rec, plug) => audit.write(rec, plug),
|
||||
onError: (err) => mainLogger.warn("logger-proxy error", { code: err.code }),
|
||||
onInvalidFrame: (raw, cause) =>
|
||||
mainLogger.warn("logger-proxy invalid frame", { raw, cause }),
|
||||
maxBatchSize: 5_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createLoggerProxyServer(port, logger, options?): LoggerProxyServer`
|
||||
— primary entry.
|
||||
- `LoggerProxyServer.close()` — detach. Idempotent.
|
||||
- `OnRecord(record, plugin)` — post-redaction, pre-dispatch hook.
|
||||
- `OnInvalidFrame(input, error)` — fires on Zod parse failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`logger.child` is optional on `LoggerLike`; when absent the server
|
||||
merges `plugin` / `plugin_version` into the call-site `ctx` instead.
|
||||
The child-logger cache is a `Map` keyed by `${name}@${version}` so
|
||||
that workers reusing the same identity (the common case) hit the
|
||||
cache even after structured-clone has produced a fresh object on the
|
||||
receive side of the port. Sink
|
||||
failures (the host logger throws while writing) are caught per-record
|
||||
so a single bad record cannot stall the listener; the error surfaces
|
||||
via `onError`.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `conformance`
|
||||
|
||||
## Purpose
|
||||
|
||||
`runLoggerProxyConformance` builds an in-process harness that exercises
|
||||
the full client → port → server → main-logger pipeline using a real
|
||||
`MessageChannel`. It is the recommended fixture for any test that needs
|
||||
to assert end-to-end behaviour of the proxy: round-trip of every level,
|
||||
plugin pre-tagging, batching behaviour, backpressure-drop accounting,
|
||||
and control-channel transitions.
|
||||
|
||||
The harness wires a minimal `LoggerLike` whose level methods push into
|
||||
a `forwarded` array, so assertions look like normal data assertions
|
||||
rather than mock-based call recording. There are no mocks; every
|
||||
component is the real production object.
|
||||
|
||||
## Features
|
||||
|
||||
- Real `MessageChannel`-backed port pair.
|
||||
- Real client + server + control sender.
|
||||
- `forwarded: ForwardedRecord[]` captures every record dispatched to
|
||||
the main logger after redaction and level narrowing.
|
||||
- `dispose()` tears down everything cleanly.
|
||||
- `ALL_LEVELS` constant for parametric tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runLoggerProxyConformance } from "@suaveplan/api-logger-worker-proxy/testing";
|
||||
|
||||
const h = runLoggerProxyConformance({ name: "x", version: "0.1.0" });
|
||||
h.client.info("hi");
|
||||
h.client.flush();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(h.forwarded[0]?.level).toBe("info");
|
||||
h.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Exercise the control channel:
|
||||
|
||||
```ts
|
||||
import { runLoggerProxyConformance } from "@suaveplan/api-logger-worker-proxy/testing";
|
||||
|
||||
const h = runLoggerProxyConformance({ name: "x", version: "0.1.0" });
|
||||
h.controlSender.disable();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
h.client.info("dropped");
|
||||
h.client.flush();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(h.forwarded.length).toBe(0);
|
||||
h.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runLoggerProxyConformance(plugin, options?): ConformanceHarness`
|
||||
- `ConformanceHarness.client` — `LoggerProxyClient`.
|
||||
- `ConformanceHarness.server` — `LoggerProxyServer`.
|
||||
- `ConformanceHarness.forwarded` — `ForwardedRecord[]`.
|
||||
- `ConformanceHarness.controlSender` — `ControlChannelSender`.
|
||||
- `ConformanceHarness.dispose()` — clean teardown.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness's `mainLogger` does not implement `logger.child`, so every
|
||||
record is forwarded with `plugin` / `plugin_version` merged into the
|
||||
context map (the no-child fallback path in the server). Tests that
|
||||
exercise the child-binding path supply a custom `LoggerLike` directly
|
||||
via `createLoggerProxyServer`. The captured array is mutated by
|
||||
push — order matches dispatch order.
|
||||
@@ -0,0 +1,92 @@
|
||||
# `testing`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
In-process test fixtures for `@suaveplan/api-logger-worker-proxy`.
|
||||
Provides a `MessageChannel`-backed `PortLike` pair so conformance and
|
||||
unit tests can wire the worker-side client to the main-side server
|
||||
without spawning a real `Worker`. Also ships a `CapturedSink`
|
||||
in-memory recorder for tests that need to assert on every record that
|
||||
reached the boundary, plus a `buildRecord` helper for constructing
|
||||
`WireLogRecord`s with sensible defaults.
|
||||
|
||||
This subpath is intentionally usable from any test — including tests
|
||||
inside other packages — so the conformance harness for the proxy is
|
||||
reusable in downstream integration tests (e.g. a plugin author who
|
||||
wants to confirm their worker correctly emits records through the
|
||||
proxy).
|
||||
|
||||
## Features
|
||||
|
||||
- `createInProcessPortPair()` — paired `PortLike` ports backed by a
|
||||
real `MessageChannel`. Available natively in Node 18+, Bun, and
|
||||
browsers.
|
||||
- `PortLike` — structural type matching both Node/Bun's
|
||||
`worker_threads` `MessagePort` and the Web `MessagePort`.
|
||||
- `CapturedSink` — push-based recorder; `records` for assertions,
|
||||
`reset()` between cases.
|
||||
- `buildRecord(level, msg, ctx?, trace?, ts?)` — composes a
|
||||
`WireLogRecord` with defaults.
|
||||
- `runLoggerProxyConformance(plugin, options?)` — full paired
|
||||
harness: client + server + captured-output array + control sender
|
||||
+ `dispose()`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInProcessPortPair,
|
||||
runLoggerProxyConformance,
|
||||
} from "@suaveplan/api-logger-worker-proxy/testing";
|
||||
|
||||
const harness = runLoggerProxyConformance({
|
||||
name: "demo",
|
||||
version: "0.1.0",
|
||||
});
|
||||
harness.client.info("hello", { route: "/health" });
|
||||
harness.client.flush();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
console.log(harness.forwarded); // [{ level: "info", msg: "hello", ctx: { ... } }]
|
||||
harness.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive `disable` / `enable` transitions and assert on backpressure:
|
||||
|
||||
```ts
|
||||
import { runLoggerProxyConformance } from "@suaveplan/api-logger-worker-proxy/testing";
|
||||
|
||||
const harness = runLoggerProxyConformance(
|
||||
{ name: "demo", version: "0.1.0" },
|
||||
{ batching: { batching: { maxOutstandingBatches: 1, maxBatchSize: 1 } } }
|
||||
);
|
||||
harness.controlSender.disable();
|
||||
harness.client.info("ignored"); // dropped silently
|
||||
harness.controlSender.enable();
|
||||
harness.client.info("kept");
|
||||
harness.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PortLike` — `{ postMessage, addEventListener, removeEventListener,
|
||||
start?, close? }`.
|
||||
- `InProcessPortPair` — `{ workerPort, mainPort, close }`.
|
||||
- `CapturedSink` — `{ records: CapturedRecord[], push, reset }`.
|
||||
- `buildRecord(level, msg, ctx?, trace?, ts?): WireLogRecord`.
|
||||
- `runLoggerProxyConformance(plugin, options?): ConformanceHarness`.
|
||||
- `ALL_LEVELS` — every wire-format level for parametric tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`MessageChannel` is asynchronous: a `postMessage(...)` is delivered on
|
||||
the next tick, so tests must `await` a `setTimeout(0)` or similar
|
||||
before asserting on `forwarded`. The harness calls `client.flush()`
|
||||
internally only on explicit invocation — the test author drives the
|
||||
flush schedule. `dispose()` closes the channel; subsequent log calls
|
||||
on the client throw `LOGGER_PROXY.PROXY_DISPOSED`.
|
||||
Reference in New Issue
Block a user