@ayepi/log 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -209,7 +209,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
209
209
  - [`ayepi-log-transports.md`](./ayepi-log-transports.md)
210
210
  - [`ayepi-log.md`](./ayepi-log.md)
211
211
 
212
- They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/log) and are **not** shipped in the npm tarball.
212
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/log).
213
213
 
214
214
  ## License
215
215
 
@@ -0,0 +1,171 @@
1
+ <!--
2
+ ayepi-log-errors-console.md — reference for `@ayepi/log` error serialization & console
3
+ interception, written for coding agents.
4
+
5
+ Copy this file into any project that depends on `@ayepi/log` (e.g. into your repo's
6
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
7
+ It documents the public API, the patterns the package expects, and how it works under the
8
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
9
+ -->
10
+
11
+ # `@ayepi/log` — Error serialization & console interception
12
+
13
+ Part of the `@ayepi/log` doc set (see `ayepi-log.md` for the overview and index). This
14
+ file covers how `Error` arguments are serialized and how to opt into `console.*`
15
+ interception.
16
+
17
+ ## Error serialization
18
+
19
+ `Error` args passed to `log()` (and the exported `serializeError`) produce a
20
+ `SerializedError`:
21
+
22
+ ```ts
23
+ interface SerializedError {
24
+ readonly name: string
25
+ readonly message: string
26
+ readonly stack?: string
27
+ readonly cause?: unknown // recursively serialized if an Error, depth-bounded
28
+ readonly [key: string]: unknown // own enumerable props (e.g. code, statusCode)
29
+ }
30
+
31
+ function serializeError(err: unknown, cfg?: ErrorCaptureConfig): SerializedError
32
+ ```
33
+
34
+ The first `Error` arg becomes the record's `error`; any further `Error` args become
35
+ `additionalErrors`. Non‑`Error` values become `{ name: 'NonError', message }` (the message
36
+ is the string itself, or `String(value)`, guarded against unstringifiable values).
37
+
38
+ ```ts
39
+ const e1 = new Error('first'); (e1 as { code?: string }).code = 'E1'
40
+ const e2 = new TypeError('second')
41
+
42
+ log.error('failed', e1, e2)
43
+ // record.error = { name:'Error', message:'first', stack:'…', code:'E1' }
44
+ // record.additionalErrors = [{ name:'TypeError', message:'second', stack:'…' }]
45
+ ```
46
+
47
+ ### Configuration — `ErrorConfig` / `ErrorCaptureConfig`
48
+
49
+ What's captured is configured via `LoggerConfig.error`, with optional per‑level overrides
50
+ (shallow‑merged over the base):
51
+
52
+ ```ts
53
+ interface ErrorCaptureConfig {
54
+ readonly stack?: boolean // include error.stack (default true)
55
+ readonly cause?: boolean // recurse into error.cause (default true)
56
+ readonly fields?: boolean // include own props like code/statusCode (default true)
57
+ readonly maxCauseDepth?: number // max cause recursion depth (default 5)
58
+ }
59
+
60
+ interface ErrorConfig extends ErrorCaptureConfig {
61
+ readonly perLevel?: Partial<Record<Level, ErrorCaptureConfig>>
62
+ }
63
+ ```
64
+
65
+ ```ts
66
+ // Full stacks at error, but drop them for warn:
67
+ const log = createLogger({
68
+ error: { stack: true, perLevel: { warn: { stack: false } } },
69
+ })
70
+ log.error('x', new Error('e')) // record.error.stack is a string
71
+ log.warn('y', new Error('e')) // record.error.stack is undefined
72
+ ```
73
+
74
+ ### `cause` chains
75
+
76
+ `cause` is recursed when it's an `Error` (up to `maxCauseDepth`, default 5) and copied
77
+ as‑is when it's a non‑Error value:
78
+
79
+ ```ts
80
+ const root = new Error('root')
81
+ const wrap = new Error('wrap', { cause: root })
82
+
83
+ serializeError(wrap).cause // { name:'Error', message:'root', … }
84
+ serializeError(wrap, { cause: false }).cause // undefined
85
+ ```
86
+
87
+ ### Error‑attached trace context
88
+
89
+ When an error carries trace context (because it was rejected out of a `logWith` — see
90
+ `ayepi-log.md`), logging it merges that context into the record:
91
+
92
+ ```ts
93
+ const err = new Error('x')
94
+ await log.logWith({ reqId: 'r9' }, () => Promise.reject(err)).catch(() => {})
95
+ log.error('caught', err) // record includes reqId: 'r9'
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Console interception (opt‑in)
101
+
102
+ A bare `import` does **nothing** to `console`. Turn it on at creation or via
103
+ `interceptConsole()`:
104
+
105
+ ```ts
106
+ import { createLogger, interceptConsole, restoreConsole } from '@ayepi/log'
107
+
108
+ createLogger({ interceptConsole: true })
109
+ // or, on the default logger:
110
+ const restore = interceptConsole()
111
+
112
+ console.log('routed', { through: 'the logger' })
113
+ restore() // put the originals back
114
+ // or: restoreConsole()
115
+ ```
116
+
117
+ `Logger.interceptConsole()` returns a restore function; `Logger.restoreConsole()` does the
118
+ same restore. Both are **idempotent**: calling `interceptConsole()` while already
119
+ intercepting returns the same restore without re‑installing, and `restoreConsole()` after
120
+ already restored is a no‑op. Interception captures the **true original** of each method so
121
+ restore is exact.
122
+
123
+ ### Default method → level mapping (`CONSOLE_LEVEL_MAP`)
124
+
125
+ | console method | level |
126
+ | --- | --- |
127
+ | `log`, `info`, `dir` | `info` |
128
+ | `debug`, `trace` | `debug` |
129
+ | `warn` | `warn` |
130
+ | `error` | `error` |
131
+
132
+ Object args passed to `console.*` are merged into the record just like normal `log()` args:
133
+
134
+ ```ts
135
+ console.log('hello', { a: 1 }) // → record { level:'info', msg:'hello', a:1 }
136
+ console.dir({ d: 1 }) // → record { level:'info', msg:'', d:1 }
137
+ ```
138
+
139
+ Override the mapping with `LoggerConfig.consoleMap` (keys are method names, values are
140
+ levels):
141
+
142
+ ```ts
143
+ createLogger({
144
+ interceptConsole: true,
145
+ consoleMap: { log: 'debug', info: 'info', warn: 'warn', error: 'error' },
146
+ })
147
+ ```
148
+
149
+ ### Recursion safety
150
+
151
+ The default console transport writes through the **captured original** console, and the
152
+ logger has a reentrancy guard so that even a custom transport which logs through the
153
+ intercepted `console.*` cannot recurse infinitely — the nested intercepted call is
154
+ short‑circuited.
155
+
156
+ ---
157
+
158
+ ## Gotchas
159
+
160
+ - **Interception is global state.** Replacing `console.*` affects every consumer of that
161
+ console in the process; always keep the restore function and call it (e.g. in tests'
162
+ teardown).
163
+ - **`info` maps to `info` level, but `log` also maps to `info`** by default — both land at
164
+ `info`. Adjust via `consoleMap` if you want `console.log` at `debug`.
165
+ - **Below‑threshold console calls are dropped** like any other log (e.g. `console.debug`
166
+ with a logger at `level: 'info'`).
167
+ - **Inject a `console`** via `LoggerConfig.console` to intercept a non‑global console (used
168
+ heavily in tests).
169
+
170
+ See `ayepi-log.md` for the overview and `ayepi-log-transports.md` /
171
+ `ayepi-log-middleware.md` for the rest of the doc set.
@@ -0,0 +1,238 @@
1
+ <!--
2
+ ayepi-log-middleware.md — reference for `@ayepi/log/middleware` and internals, written for
3
+ coding agents.
4
+
5
+ Copy this file into any project that depends on `@ayepi/log` (e.g. into your repo's
6
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
7
+ It documents the public API, the patterns the package expects, and how it works under the
8
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
9
+ -->
10
+
11
+ # `@ayepi/log` — Middleware, merge & internals
12
+
13
+ Part of the `@ayepi/log` doc set (see `ayepi-log.md` for the overview and index). This
14
+ file covers the `@ayepi/log/middleware` ayepi integration (its `@ayepi/log/server` impl
15
+ binder), the exported collision‑renaming `merge`, and how the package works under the hood.
16
+
17
+ ## `@ayepi/log/middleware` + `@ayepi/log/server`
18
+
19
+ The ayepi middleware follows core's **def/impl split**: a frontend‑safe **def** declared in
20
+ the spec, plus a server‑only **impl** bound with `implement(api).middleware(...)`.
21
+
22
+ - **`@ayepi/log/middleware`** — frontend‑safe, **no `node:async_hooks`**. `logMiddleware(opts?)`
23
+ is a **def factory**: a no‑context middleware that establishes log trace context for the
24
+ downstream chain. Put this in shared/frontend code and your spec.
25
+ - **`@ayepi/log/server`** — the impl, which pulls in `node:async_hooks` through the package
26
+ internals. `logMiddleware` here is **augmented** with `.server(def, { context, logWith? })`,
27
+ the binder that wraps `io.next()` in `logWith(...)` so the **entire downstream chain, the
28
+ handler, and any error they throw** run inside that context. Put this only in server code.
29
+
30
+ See `ayepi-core-middleware.md` for how middleware, `requires`, stacks, `.group()` /
31
+ `.endpoint()`, and `implement(api).middleware(def, impl)` work.
32
+
33
+ ```ts
34
+ // @ayepi/log/middleware — frontend-safe def factory
35
+ function logMiddleware<const R extends readonly AnyMiddleware[] = readonly []>(
36
+ opts?: LogMiddlewareOptions<R>,
37
+ ): MiddlewareDef<Provides, R, StackLP<R>>
38
+
39
+ interface LogMiddlewareOptions<R extends readonly AnyMiddleware[]> {
40
+ /** Middleware this one depends on — their context is available (and typed) in `.server`'s `context`. */
41
+ readonly requires?: R
42
+ /** Middleware name for docs/debugging (default 'log'). */
43
+ readonly name?: string
44
+ }
45
+
46
+ // @ayepi/log/server — the impl binder (augmented onto logMiddleware)
47
+ logMiddleware.server<Def>(
48
+ def: Def,
49
+ opts: LogServerOptions<StackCtx<Def>>,
50
+ ): MiddlewareImpl<Def>
51
+
52
+ interface LogServerOptions<Ctx extends object> {
53
+ /** Build the context object to push for the downstream chain + handler. */
54
+ readonly context: (ctx: Ctx, req: Request) => object
55
+ /** logWith to use (default the package's shared trace context).
56
+ * Pass a specific logger's logWith to scope it. */
57
+ readonly logWith?: <T>(add: object, inner: () => T) => T
58
+ /** Observe an error from building/pushing the context (off by default). The middleware is
59
+ * fail-open: a throwing `context`/`logWith` runs the chain anyway, without the context. */
60
+ readonly onError?: (err: unknown) => void
61
+ }
62
+ ```
63
+
64
+ The def provides **nothing** to the handler context (`Provides = Record<never, never>`) — it
65
+ only establishes the log context. The `context` builder and optional `logWith` live on the
66
+ server side, in `.server`; the `requires` middleware flow their (typed) context into the
67
+ `context` callback. Internally the impl just does `wrap(opts.context(io.ctx, io.req),
68
+ () => io.next())`, where `wrap` is the `logWith` option (default the package's shared
69
+ `logWith`).
70
+
71
+ Every middleware in a chain must be bound: if you mount a `logMiddleware` def but never
72
+ bind its impl with `.middleware(...)`, `server()` throws.
73
+
74
+ ### Wiring into a server
75
+
76
+ The def goes in your spec (and any shared/frontend code); the impl is bound on the
77
+ chainable `implement(api)` builder.
78
+
79
+ ```ts
80
+ // shared.ts — frontend-safe: def only, no node:async_hooks
81
+ import { spec } from '@ayepi/core'
82
+ import { logMiddleware } from '@ayepi/log/middleware'
83
+ import { z } from 'zod'
84
+
85
+ export const trace = logMiddleware()
86
+ export const api = spec({
87
+ endpoints: {
88
+ ping: trace.endpoint({ response: z.object({ path: z.string() }) }),
89
+ },
90
+ })
91
+ ```
92
+
93
+ ```ts
94
+ // server.ts — server-only: bind the impl (pulls in node:async_hooks)
95
+ import { implement, server } from '@ayepi/core'
96
+ import { logMiddleware } from '@ayepi/log/server'
97
+ import { context } from '@ayepi/log'
98
+ import { api, trace } from './shared'
99
+
100
+ const app = server(api, [
101
+ implement(api)
102
+ .middleware(logMiddleware.server(trace, {
103
+ context: (_ctx, req) => ({ reqId: crypto.randomUUID(), path: new URL(req.url).pathname }),
104
+ }))
105
+ .handlers({
106
+ ping: () => ({ path: (context().path as string) ?? 'none' }),
107
+ }),
108
+ ])
109
+ // Any log inside the handler — or a deeper async call — carries { reqId, path }.
110
+ ```
111
+
112
+ Use `trace.group({ … })` to apply the def across a group of endpoints, exactly like any
113
+ core middleware def (see `ayepi-core-middleware.md`).
114
+
115
+ ### Typed `requires`
116
+
117
+ `requires` is declared on the **def** (frontend‑safe); the context from those middleware is
118
+ available and typed in the `.server` `context` callback:
119
+
120
+ ```ts
121
+ import { middleware } from '@ayepi/core'
122
+
123
+ // shared.ts — def
124
+ const auth = middleware('auth') // the auth def
125
+ const trace = logMiddleware({ requires: [auth] })
126
+
127
+ // server.ts — impl
128
+ implement(api).middleware(logMiddleware.server(trace, {
129
+ context: (ctx) => ({ userId: ctx.user.id }), // ctx.user is typed from auth's def
130
+ }))
131
+ ```
132
+
133
+ ### The `logWith` option — scoping to a specific logger
134
+
135
+ By default the impl uses the package's **shared** trace context (the same
136
+ `AsyncLocalStorage` the default logger and the top‑level `logWith`/`context` read). To call
137
+ through a particular logger instance, pass that logger's `logWith` in `.server`:
138
+
139
+ ```ts
140
+ const myLog = createLogger({ structured: true })
141
+ implement(api).middleware(logMiddleware.server(trace, {
142
+ context: (_ctx, req) => ({ path: new URL(req.url).pathname }),
143
+ logWith: myLog.logWith, // call through myLog
144
+ }))
145
+ ```
146
+
147
+ It also accepts any wrapper of the shape `<T>(add, inner) => T`, useful for tests:
148
+
149
+ ```ts
150
+ const seen: object[] = []
151
+ logMiddleware.server(trace, {
152
+ context: () => ({ x: 1 }),
153
+ logWith: (add, inner) => { seen.push(add); return inner() },
154
+ })
155
+ ```
156
+
157
+ > Important: there is **no `logWith` hook on `server()`**. The only integration point is
158
+ > this middleware wrapping `io.next()`. And because every `createLogger` instance shares
159
+ > **one** global `AsyncLocalStorage`, the `logWith` option selects which logger object you
160
+ > call through, but the underlying store is the same — `context()` anywhere observes the
161
+ > same fields. The option exists for explicitness and for injecting a custom wrapper.
162
+
163
+ ---
164
+
165
+ ## Collision‑renaming `merge` (and `deepEqual`)
166
+
167
+ Object args, ambient context, and error‑attached context are combined with an immutable
168
+ **collision‑renaming `merge`** (exported, along with `deepEqual`):
169
+
170
+ ```ts
171
+ function merge(a: Record<string, unknown>, b: Record<string, unknown>): Record<string, unknown>
172
+ function deepEqual(a: unknown, b: unknown): boolean
173
+ ```
174
+
175
+ `a` keeps all of its keys. Each key of `b` goes in the first free slot among
176
+ `key, key2, key3, …`, **unless** an existing slot already deep‑equals `b`'s value (then
177
+ it's deduped and dropped). Neither input is mutated.
178
+
179
+ ```ts
180
+ merge({ a: 1 }, { a: 2 }) // { a: 1, a2: 2 }
181
+ merge({ a: 1, a2: 9 }, { a: 2 }) // { a: 1, a2: 9, a3: 2 }
182
+ merge({ a: 1 }, { a: 1 }) // { a: 1 } (deduped)
183
+ merge({ a: 1 }, { b: 2 }) // { a: 1, b: 2 }
184
+ ```
185
+
186
+ `deepEqual` handles primitives (incl. `NaN`), `Date`, arrays, `Error` (by name + message),
187
+ plain objects, and cycles. This is why ambient request fields like `reqId` / `userId` stay
188
+ on their bare key across every log in a request, while a colliding call‑site value lands on
189
+ `reqId2`.
190
+
191
+ ---
192
+
193
+ ## How it works under the hood
194
+
195
+ - **AsyncLocalStorage propagation.** The package owns a single module‑level
196
+ `AsyncLocalStorage<Record<string, unknown>>`. `logWith` computes
197
+ `merge(currentStore, add)` and runs `inner` via `store.run(merged, inner)`. Because ALS
198
+ context survives `await`, every log emitted anywhere inside that call tree reads the same
199
+ merged object through `getContext()`. Outside any `logWith`, the store is empty (`{}`).
200
+ - **Error tagging across async.** When `inner` returns a thenable, `logWith` attaches a
201
+ rejection handler that, on failure, defines a non‑enumerable `LOG_CONTEXT` property on
202
+ the error carrying the merged context (only if not already present — innermost wins),
203
+ then re‑throws. When that error is later passed to `log.error(err)`, the record builder
204
+ reads `err[LOG_CONTEXT]` and merges it in, so the catch‑site log reflects the throw‑site
205
+ context.
206
+ - **Record building.** The builder partitions args into messages / objects / errors,
207
+ serializes errors per the effective (per‑level‑merged) `ErrorConfig`, then merges in
208
+ order: reserved fields → ambient context → object args → error‑attached contexts.
209
+ - **Console interception.** Installation replaces each method named in `consoleMap` on the
210
+ target console with a closure that emits at the mapped level, saving the true original
211
+ for restore. The default console transport writes through the captured original console,
212
+ and `emit` has a reentrancy guard so a transport that logs through the intercepted
213
+ console can't recurse infinitely.
214
+ - **File transport batching.** `write()` pushes a line into an in‑memory buffer and either
215
+ schedules a flush (`flushInterval`, with an unref'd timer) or forces one immediately once
216
+ the buffer crosses `maxBufferBytes`. `flush()` joins the buffer into one batch, does at
217
+ most one append per flush with one flush in flight, lazily stats the file once for size
218
+ rotation, and rotates/prunes as needed — all async, all best‑effort.
219
+
220
+ ---
221
+
222
+ ## Gotchas
223
+
224
+ - **`@ayepi/core` is an optional peer dependency**, required only for the middleware
225
+ entries (`/middleware` and `/server`). The main and `/file` entries don't need it.
226
+ - **Keep the def frontend‑safe.** Import `logMiddleware` from `@ayepi/log/middleware` in
227
+ shared/frontend code and your spec — it has **no `node:async_hooks`**. Only `server.ts`
228
+ should import from `@ayepi/log/server`, which pulls in `node:async_hooks`.
229
+ - **Bind every middleware.** A `logMiddleware` def mounted in the chain must be bound with
230
+ `implement(api).middleware(logMiddleware.server(def, …))`, or `server()` throws.
231
+ - **No `server()` logging hook.** Wire trace context exclusively through `logMiddleware`.
232
+ - **One shared trace store.** You can't get two fully isolated context stores by creating
233
+ two loggers; the `logWith` option selects the call‑through logger, not a separate store.
234
+ - **`context()` returns a frozen snapshot**; `getContext()` (also exported) returns the
235
+ live store object — prefer `context()` in application code.
236
+
237
+ See `ayepi-log.md` for the overview, and `ayepi-log-transports.md` /
238
+ `ayepi-log-errors-console.md` for the rest of the doc set.
@@ -0,0 +1,205 @@
1
+ <!--
2
+ ayepi-log-transports.md — reference for `@ayepi/log` transports, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/log` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/log` — Transports
11
+
12
+ Part of the `@ayepi/log` doc set (see `ayepi-log.md` for the overview and index). This
13
+ file covers the `Transport` interface, the built‑in `consoleTransport`, and the
14
+ non‑blocking rotating `fileTransport` from `@ayepi/log/file`.
15
+
16
+ ## The `Transport` interface
17
+
18
+ ```ts
19
+ interface Transport {
20
+ readonly name: string
21
+ /** Write one record; `text` is the pre-formatted line. May be async; the logger never awaits it. */
22
+ write(record: LogRecord, text: string): void | Promise<void>
23
+ /** Optional: drain buffered writes without tearing down. Driven by `logger.flush()`. */
24
+ flush?(): void | Promise<void>
25
+ /** Optional: flush + release resources. Driven by `logger.close()`. */
26
+ close?(): void | Promise<void>
27
+ }
28
+ ```
29
+
30
+ `logger.flush()` calls every transport's `flush?()`; `logger.close()` calls every `close?()`.
31
+ Both run in parallel and route a throwing transport to `onError` (they never reject), so one
32
+ bad transport can't abort shutdown. The file transport implements both (`flush` drains the
33
+ buffer; `close` does the same).
34
+
35
+ The logger writes the same record to **every** configured transport, fire‑and‑forget:
36
+
37
+ - a transport that **throws** never breaks logging (the error is swallowed);
38
+ - returned **promises are not awaited**;
39
+ - `setTransports(list)` swaps the transport list at runtime.
40
+
41
+ A minimal in‑memory transport (handy for tests):
42
+
43
+ ```ts
44
+ import { createLogger, type LogRecord, type Transport } from '@ayepi/log'
45
+
46
+ const records: LogRecord[] = []
47
+ const mem: Transport = { name: 'mem', write: (r) => void records.push(r) }
48
+ const log = createLogger({ transports: [mem] })
49
+ ```
50
+
51
+ A capture transport that grabs the formatted text:
52
+
53
+ ```ts
54
+ let line = ''
55
+ const cap: Transport = { name: 'cap', write: (_r, text) => { line = text } }
56
+ ```
57
+
58
+ ---
59
+
60
+ ## `consoleTransport(opts?)`
61
+
62
+ ```ts
63
+ function consoleTransport(opts?: ConsoleTransportOptions): Transport
64
+
65
+ interface ConsoleTransportOptions {
66
+ /** The console to write through — should be the original (pre-interception) console to avoid recursion. */
67
+ readonly console?: ConsoleLike
68
+ /** Map a record level to a console method (default: error→error, warn→warn, debug→debug, else→log). */
69
+ readonly method?: (level: Level) => keyof ConsoleLike
70
+ }
71
+
72
+ interface ConsoleLike {
73
+ log(...args: unknown[]): void
74
+ info(...args: unknown[]): void
75
+ debug(...args: unknown[]): void
76
+ warn(...args: unknown[]): void
77
+ error(...args: unknown[]): void
78
+ }
79
+ ```
80
+
81
+ Writes the pre‑formatted `text` to the level‑mapped console method. The default level →
82
+ method mapping is `error → error`, `warn → warn`, `debug → debug`, everything else →
83
+ `log`.
84
+
85
+ The default logger's default transport is a `consoleTransport` bound to the **captured
86
+ original** console (taken before any interception is installed), so logs aren't recursed
87
+ even when console interception is on. If `globalThis.console` is absent it falls back to a
88
+ no‑op console (no throw).
89
+
90
+ ```ts
91
+ import { createLogger, consoleTransport } from '@ayepi/log'
92
+
93
+ // Route everything to console.error, e.g. for stderr-only logging:
94
+ createLogger({ transports: [consoleTransport({ method: () => 'error' })] })
95
+ ```
96
+
97
+ ---
98
+
99
+ ## `fileTransport(opts)` — `@ayepi/log/file`
100
+
101
+ A Node file transport with rotation, built for heavy load. `write()` is **non‑blocking**:
102
+ it appends to an in‑memory buffer and returns immediately. Buffered lines flush to disk in
103
+ **batches** — one append per flush, at most one flush in flight — so callers never wait on
104
+ I/O and the FS isn't hit with a syscall per line. Everything touching the filesystem uses
105
+ `node:fs/promises`, so a flush never blocks the event loop. It **defaults to structured
106
+ JSON lines** regardless of the logger's text/JSON setting.
107
+
108
+ ```ts
109
+ function fileTransport(opts: FileTransportOptions): Transport
110
+
111
+ interface FileTransportOptions {
112
+ /** Target file path (e.g. './logs/app.log'). The directory is created if missing. */
113
+ readonly path: string
114
+ /** Rotate when the active file would exceed this many bytes (default 10 MiB). */
115
+ readonly maxSize?: number
116
+ /** Keep at most this many rotated/dated files (default 5). */
117
+ readonly maxFiles?: number
118
+ /** Write structured JSON lines regardless of the logger's text/json setting (default true). */
119
+ readonly structured?: boolean
120
+ /** Rotation strategy (default 'size'). */
121
+ readonly strategy?: 'size' | 'date'
122
+ /** Flush the buffer at most this often, in ms (default 250). */
123
+ readonly flushInterval?: number
124
+ /** Force an immediate flush once the buffer reaches this many bytes (default 256 KiB). */
125
+ readonly maxBufferBytes?: number
126
+ /** Injected fs (default node:fs/promises). */
127
+ readonly fs?: FsLike
128
+ /** Observe a background flush failure (disk full / permission denied). Best-effort: a failed
129
+ * flush never rejects; this hook lets you notice. Off by default. */
130
+ readonly onError?: (err: unknown) => void
131
+ /** Injected clock for date rotation/naming (default () => Date.now()). */
132
+ readonly now?: () => number
133
+ }
134
+ ```
135
+
136
+ ### Rotation strategies
137
+
138
+ - **`'size'`** (default): keeps `app.log` bounded to `maxSize`, shifting
139
+ `app.log → app.log.1 → app.log.2 → …` and pruning beyond `maxFiles`. On rotation the
140
+ oldest (`app.log.{maxFiles}`) is deleted first, then files shift up, then the active
141
+ `app.log → app.log.1`. The active size is statted lazily (once) to avoid a `stat` per
142
+ write.
143
+ - **`'date'`**: writes `app-YYYY-MM-DD.log` (date from `now()`), pruning dated files
144
+ beyond `maxFiles` (oldest ISO date first).
145
+
146
+ ### Flushing & shutdown
147
+
148
+ `close()` flushes the buffer — wire it to a shutdown hook (e.g. an `@ayepi/updown`
149
+ shutdown hook) so the last batch isn't lost on exit. The internal flush timer is
150
+ `unref`'d, so a pending flush won't keep the process alive on its own.
151
+
152
+ ```ts
153
+ import { createLogger } from '@ayepi/log'
154
+ import { fileTransport } from '@ayepi/log/file'
155
+
156
+ const file = fileTransport({
157
+ path: './logs/app.log',
158
+ maxSize: 10 * 1024 * 1024,
159
+ maxFiles: 7,
160
+ })
161
+ const log = createLogger({ structured: true, transports: [file] })
162
+
163
+ // on shutdown:
164
+ await file.close?.()
165
+ ```
166
+
167
+ Date rotation:
168
+
169
+ ```ts
170
+ const file = fileTransport({ path: './logs/app.log', strategy: 'date', maxFiles: 14 })
171
+ // writes ./logs/app-2026-06-16.log, rolling to a new file each day
172
+ ```
173
+
174
+ ### `FsLike`
175
+
176
+ The async fs surface the transport uses (`node:fs/promises` satisfies it). Exported so
177
+ tests can inject a deterministic in‑memory fs and assert rotation/prune behavior:
178
+
179
+ ```ts
180
+ interface FsLike {
181
+ exists(path: string): Promise<boolean>
182
+ stat(path: string): Promise<{ size: number }>
183
+ mkdir(path: string, opts: { recursive: true }): Promise<void>
184
+ appendFile(path: string, data: string): Promise<void> // the hot path
185
+ rename(from: string, to: string): Promise<void>
186
+ unlink(path: string): Promise<void>
187
+ readdir?(path: string): Promise<string[]> // required for date pruning
188
+ }
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Gotchas
194
+
195
+ - **Transports are fire‑and‑forget.** The logger never awaits `write()` and swallows its
196
+ throws. The file transport may still be buffering when a call returns — call `close()`
197
+ on shutdown to flush.
198
+ - **The file transport defaults to JSON lines** even if the logger is in text mode. Pass
199
+ `structured: false` to write the text format (`text` argument) instead.
200
+ - **Directory creation is lazy**, on first flush — not at construction time.
201
+ - **Best‑effort I/O.** A flush that fails (or a failed rotate/prune) is swallowed, never
202
+ rejected — file logging never crashes the app.
203
+
204
+ See `ayepi-log.md` for the overview and `ayepi-log-errors-console.md` /
205
+ `ayepi-log-middleware.md` for the rest of the doc set.
package/ayepi-log.md ADDED
@@ -0,0 +1,470 @@
1
+ <!--
2
+ ayepi-log.md — reference for `@ayepi/log`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/log` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/log`
11
+
12
+ Structured logging built around an **AsyncLocalStorage trace context**: you stack
13
+ context with `logWith(...)` and every log emitted inside that async call tree — and
14
+ any `Error` thrown out of it — automatically carries those fields. Records are built
15
+ from mixed primitive/object/`Error` arguments, formatted as text (default) or JSON,
16
+ and written to pluggable **transports** (a console transport in the main entry, a
17
+ non‑blocking rotating **file transport** in `@ayepi/log/file`). It can optionally
18
+ intercept `console.*`, and ships an ayepi **middleware** — a frontend‑safe def
19
+ (`@ayepi/log/middleware`) bound by a server impl (`@ayepi/log/server`) — that pushes
20
+ per‑request trace context for an entire endpoint chain. Reach for it when
21
+ you want request‑scoped, traceable structured logs in an ayepi service (or any Node
22
+ app). A bare `import` has **no side effects** — console interception is opt‑in.
23
+
24
+ ```sh
25
+ pnpm add @ayepi/log
26
+ ```
27
+
28
+ This package builds on `@ayepi/core` middleware concepts — see `ayepi-core.md` and
29
+ `ayepi-core-middleware.md` for `middleware()`, `spec()`, `server()`, stacks, and
30
+ `.group()` / `.endpoint()`.
31
+
32
+ ## This doc set
33
+
34
+ This reference is split by topic:
35
+
36
+ - **`ayepi-log.md`** (this file) — overview, entry points, `createLogger` / `Logger`,
37
+ log levels, record building, the trace context (`logWith` / `context`), formatting,
38
+ and the `filter` hook.
39
+ - **`ayepi-log-transports.md`** — the `Transport` interface, `consoleTransport`, and the
40
+ non‑blocking rotating `fileTransport` (`@ayepi/log/file`) with all its options.
41
+ - **`ayepi-log-errors-console.md`** — error serialization (`serializeError`, `ErrorConfig`,
42
+ per‑level overrides) and opt‑in `console.*` interception.
43
+ - **`ayepi-log-middleware.md`** — the `@ayepi/log/middleware` def + `@ayepi/log/server`
44
+ impl ayepi integration, plus the collision‑renaming `merge` and a "how it works under the
45
+ hood" section.
46
+
47
+ ## Entry points
48
+
49
+ | Import | Exposes |
50
+ | --- | --- |
51
+ | `@ayepi/log` | `createLogger`, `consoleTransport`, the default logger + bound convenience functions, `logWith`/`context`, console interception, `logMaybe`, `createSanitizer`/`partialMask`, `resolveLogValue`, `merge`/`deepEqual`/`serializeError`/`getContext`, and all types |
52
+ | `@ayepi/log/file` | `fileTransport`, `FileTransportOptions`, `FsLike` |
53
+ | `@ayepi/log/middleware` | `logMiddleware` def factory, `LogMiddlewareOptions` — frontend‑safe, **no `node:async_hooks`** (peer‑depends on `@ayepi/core`) |
54
+ | `@ayepi/log/server` | `logMiddleware` augmented with `.server(def, { context, logWith? })`, `LogServerOptions` — the impl binder, pulls in `node:async_hooks` (peer‑depends on `@ayepi/core`) |
55
+
56
+ ---
57
+
58
+ ## Quick start
59
+
60
+ ```ts
61
+ import { createLogger } from '@ayepi/log'
62
+
63
+ const log = createLogger({ level: 'debug' })
64
+
65
+ log.logWith({ reqId: 'abc' }, async () => {
66
+ log.info('handling', { userId: 'u1' }) // record carries reqId + userId
67
+ await work() // a rejection here is tagged with { reqId, userId }
68
+ })
69
+ ```
70
+
71
+ There is also a ready‑made **default logger** (level `info`, text output, writes to the
72
+ captured console) with bound top‑level functions, so you don't have to create one:
73
+
74
+ ```ts
75
+ import { info, error, logWith, context } from '@ayepi/log'
76
+
77
+ info('server started', { port: 3000 })
78
+ logWith({ reqId: 'r1' }, () => error('boom', new Error('nope')))
79
+ ```
80
+
81
+ The full set of default‑logger bindings: `log`, `debug`, `info`, `warn`, `error`,
82
+ `logWith`, `context`, `interceptConsole`, `restoreConsole`, and `logger` (the instance
83
+ itself).
84
+
85
+ ---
86
+
87
+ ## Levels
88
+
89
+ ```ts
90
+ type Level = 'debug' | 'info' | 'warn' | 'error'
91
+ ```
92
+
93
+ Severity ordering (`debug < info < warn < error`). A logger emits a record only if its
94
+ level is `>=` the configured threshold; below‑threshold calls are dropped **before** a
95
+ record is built (cheap to leave in). Default threshold is `'info'`.
96
+
97
+ ---
98
+
99
+ ## `createLogger(config?)`
100
+
101
+ ```ts
102
+ function createLogger(config?: LoggerConfig): Logger
103
+ ```
104
+
105
+ ### `LoggerConfig`
106
+
107
+ ```ts
108
+ interface LoggerConfig {
109
+ /** Minimum level emitted (default 'info'). Logs below this are dropped before a record is built. */
110
+ readonly level?: Level
111
+ /** Structured JSON output vs `[tms] level msg key=value` text (default false = text). */
112
+ readonly structured?: boolean
113
+ /** Timestamp format — ISO string (default) or numeric epoch ms. */
114
+ readonly timestamp?: 'iso' | 'epoch'
115
+ /** Transports to write to (default: a single consoleTransport bound to the captured original console). */
116
+ readonly transports?: readonly Transport[]
117
+ /** Intercept global console.* immediately (default false — opt-in). */
118
+ readonly interceptConsole?: boolean
119
+ /** console method → level mapping for interception (default CONSOLE_LEVEL_MAP). */
120
+ readonly consoleMap?: Readonly<Record<string, Level>>
121
+ /** The console to read originals from / intercept (default the global console). */
122
+ readonly console?: ConsoleLike
123
+ /** Error serialization config, including per-level overrides. */
124
+ readonly error?: ErrorConfig
125
+ /** Final hook over the built record before formatting. Return a (possibly modified)
126
+ * record to log it, or null/undefined to drop the log entirely. Runs before `sanitize`. */
127
+ readonly filter?: (record: LogRecord) => LogRecord | null | undefined
128
+ /** Declarative redaction/truncation applied to every record (after `filter`) — for both
129
+ * direct calls and intercepted console.*. See "Sanitization" below. */
130
+ readonly sanitize?: SanitizeOptions
131
+ /** Custom serializers for types you don't own (Request/URL/Buffer/third-party). See "Value resolution". */
132
+ readonly serializers?: readonly Serializer[]
133
+ /** Observe a pipeline error — a throwing `filter`, an unserializable record, or a transport
134
+ * whose `write` throws. Logging is best-effort: the line is dropped, never thrown. Off by default. */
135
+ readonly onError?: (err: unknown) => void
136
+ /** Clock injection for tests (default () => Date.now()). */
137
+ readonly now?: () => number
138
+ }
139
+ ```
140
+
141
+ > Logging never throws into the caller: if building/filtering/formatting a line or a transport
142
+ > `write` fails, the line is dropped and routed to `onError` (if set). The file transport takes
143
+ > its own `onError` for background **flush** failures (disk full / permission denied).
144
+
145
+ See `ayepi-log-transports.md` for `Transport`, `ayepi-log-errors-console.md` for
146
+ `ErrorConfig` / `ConsoleLike` / `consoleMap`.
147
+
148
+ ### `Logger`
149
+
150
+ ```ts
151
+ interface Logger {
152
+ /** Emit a record at `level` from mixed primitive/object/Error args. No-op below the threshold. */
153
+ log(level: Level, ...args: unknown[]): void
154
+ debug(...args: unknown[]): void
155
+ info(...args: unknown[]): void
156
+ warn(...args: unknown[]): void
157
+ error(...args: unknown[]): void
158
+ /** Merge `add` into the current trace context, run `inner` within it, tag promise rejections. */
159
+ logWith<R>(add: object, inner: () => R): R
160
+ /** Snapshot of the current merged trace context (empty outside any logWith). */
161
+ context(): Readonly<Record<string, unknown>>
162
+ /** Replace the transports at runtime. */
163
+ setTransports(transports: readonly Transport[]): void
164
+ /** Change the minimum emitted level at runtime (e.g. bump to 'debug' on demand). */
165
+ setLevel(level: Level): void
166
+ /** Whether a record at `level` would be emitted now — guard expensive prep (cf. logMaybe). */
167
+ isLevelEnabled(level: Level): boolean
168
+ /** Drain every transport's buffered writes (e.g. the file transport) without closing them. */
169
+ flush(): Promise<void>
170
+ /** Flush AND close every transport (release timers/handles) — wire to a shutdown hook. */
171
+ close(): Promise<void>
172
+ /** The effective level/format/timestamp (`level` reflects setLevel). */
173
+ readonly config: { readonly level: Level; readonly structured: boolean; readonly timestamp: 'iso' | 'epoch' }
174
+ /** Begin intercepting console.* (idempotent); returns a restore function. */
175
+ interceptConsole(): () => void
176
+ /** Restore any console interception this logger installed (idempotent). */
177
+ restoreConsole(): void
178
+ }
179
+ ```
180
+
181
+ - **`setLevel` / `isLevelEnabled`** — change verbosity at runtime (an admin endpoint, a signal
182
+ handler) without recreating the logger; `isLevelEnabled(lvl)` guards an expensive block when
183
+ `logMaybe` doesn't fit. `config.level` reflects the current level.
184
+ - **`flush` / `close`** — `flush()` drains buffered transports (the file transport) without
185
+ tearing them down; `close()` flushes **and** releases resources. Both run every transport in
186
+ parallel and route a failing one to `onError` (never throwing), so one bad transport can't
187
+ abort shutdown. Wire `close()` into an `@ayepi/updown` teardown hook.
188
+
189
+ ---
190
+
191
+ ## Building a record — `log(level, …args)`
192
+
193
+ Each `log` / `debug` / `info` / `warn` / `error` call builds a `LogRecord` from mixed
194
+ arguments:
195
+
196
+ - **non‑object args** (strings, numbers, booleans, `null`, `undefined`) are stringified
197
+ and space‑joined into `msg`;
198
+ - **plain object args** are **merged** into the record (see the collision‑renaming `merge`
199
+ in `ayepi-log-middleware.md`);
200
+ - **`Error` args** become `error` (the first) and `additionalErrors` (the rest),
201
+ serialized; and **any trace context attached to a caught error is merged in** (so an
202
+ error logged at the catch site carries the context from where it was thrown).
203
+
204
+ ```ts
205
+ interface LogRecord {
206
+ readonly tms: string | number // ISO string (default) or epoch ms
207
+ readonly level: Level
208
+ readonly msg: string
209
+ readonly error?: SerializedError
210
+ readonly additionalErrors?: readonly SerializedError[]
211
+ readonly [key: string]: unknown // merged fields
212
+ }
213
+ ```
214
+
215
+ ```ts
216
+ log.info('done in', 42, 'ms', { req: 'x' })
217
+ // → { tms, level:'info', msg:'done in 42 ms', req:'x' }
218
+
219
+ log.error('upload failed', err, { docId })
220
+ // → { tms, level:'error', msg:'upload failed', docId, error:{ name, message, stack, cause… }, …throwSiteContext }
221
+ ```
222
+
223
+ The field‑order precedence used to build the record is:
224
+ **reserved (`tms`/`level`/`msg`/`error`/`additionalErrors`) → ambient context → object
225
+ args → error‑attached context**, all combined with the collision‑renaming `merge`. The
226
+ ambient context therefore keeps the bare key; a colliding call‑site object value lands
227
+ on `key2`.
228
+
229
+ ```ts
230
+ log.logWith({ user: 'a' }, () => log.info('hi', { user: 'b' }))
231
+ // → { user: 'a', user2: 'b', msg: 'hi', … }
232
+ ```
233
+
234
+ ### Value resolution — `toLOG` / `toJSON`
235
+
236
+ Before a value is merged/classified, it's resolved to its **loggable plain shape** (deeply),
237
+ so the resolved shape is consistent everywhere: the record object transports receive, the
238
+ `sanitize` pass, and both the JSON and text output. Two hooks are honored, then a structural
239
+ copy (mirroring `JSON.stringify` — own enumerable entries; `Error`s keep their dedicated
240
+ serialization; cycles are preserved):
241
+
242
+ - **`toLOG()`** — a **logging-specific** hook that **takes precedence over `toJSON`**. Define it
243
+ to shape a value for logs alone, without affecting `JSON.stringify` / your API responses. It
244
+ **may return a promise** — the line is then delivered asynchronously once it resolves (an
245
+ expensive or async log view is only produced when the line actually logs).
246
+ - **`toJSON(key)`** — the standard hook (e.g. `Date` → ISO string).
247
+
248
+ ```ts
249
+ class Money {
250
+ constructor(private cents: number) {}
251
+ toJSON() { return this.cents } // API: a number
252
+ toLOG() { return `$${(this.cents / 100).toFixed(2)}` } // logs: "$19.99"
253
+ }
254
+ log.info('charged', { amount: new Money(1999) }) // → { msg:'charged', amount:'$19.99' }
255
+
256
+ class Account {
257
+ async toLOG() { return { id: this.id, balance: await this.fetchBalance() } } // awaited before logging
258
+ }
259
+ ```
260
+
261
+ - A top-level value whose hook resolves to a **scalar** joins `msg`; to an **object**, merges
262
+ as fields (so a top-level `toJSON`/`toLOG` no longer clobbers the line).
263
+ - A hook that **throws or rejects** (or a raw promise value that rejects) degrades that value to
264
+ `'(unresolved value)'` and reports to `onError` — the rest of the line still logs.
265
+ - The exported **`resolveLogValue(value)`** runs this resolution standalone.
266
+
267
+ **Custom serializers** handle values you *don't* own — a `Request`, `URL`, `Buffer`, a
268
+ third-party class — where you can't add a `toLOG` hook. Configure `serializers` on the logger:
269
+ each is tried in order at every depth, the first non-`undefined` result wins, and serializers
270
+ take **precedence over** a value's own `toLOG`/`toJSON`. Return `undefined` (or throw — it's
271
+ reported) to decline to the next.
272
+
273
+ ```ts
274
+ type Serializer = (value: object) => unknown // return the shape, or undefined to decline
275
+
276
+ createLogger({
277
+ serializers: [
278
+ (v) => (v instanceof URL ? v.href : undefined),
279
+ (v) => (v instanceof Request ? { method: v.method, url: v.url } : undefined),
280
+ (v) => (Buffer.isBuffer(v) ? `<${v.length}b>` : undefined),
281
+ ],
282
+ })
283
+ ```
284
+
285
+ Precedence overall: **serializers → `toLOG` → `toJSON` → structural copy** (`Error`s keep their
286
+ dedicated serialization).
287
+
288
+ ---
289
+
290
+ ## Trace context — `logWith` / `context`
291
+
292
+ ```ts
293
+ logWith<R>(add: object, inner: () => R): R
294
+ context(): Readonly<Record<string, unknown>>
295
+ ```
296
+
297
+ `logWith(add, inner)` merges `add` into the ambient context (immutably) and runs `inner`
298
+ inside an `AsyncLocalStorage` scope carrying the merged context. Every log emitted by
299
+ `inner` — at any await depth — picks up those fields automatically.
300
+
301
+ ```ts
302
+ import { logWith, context } from '@ayepi/log'
303
+
304
+ await logWith({ reqId: 'r1' }, async () => {
305
+ context() // { reqId: 'r1' }
306
+ await new Promise((r) => setTimeout(r, 5))
307
+ context() // still { reqId: 'r1' } — propagates across awaits
308
+ })
309
+ context() // {} — restored on exit
310
+ ```
311
+
312
+ Nesting stacks (innermost merged over outer):
313
+
314
+ ```ts
315
+ logWith({ a: 1 }, () => logWith({ b: 2 }, () => context())) // { a: 1, b: 2 }
316
+ ```
317
+
318
+ ### Errors thrown out of `logWith` are tagged
319
+
320
+ If `inner` returns a **promise**, its rejection is tagged with the full merged context,
321
+ stored on the error under the `LOG_CONTEXT` symbol. The **innermost** `logWith` wins, and
322
+ an already‑tagged error is never overwritten.
323
+
324
+ ```ts
325
+ const err = new Error('boom')
326
+ await logWith({ reqId: 'r1' }, () => Promise.reject(err)).catch(() => {})
327
+ // err now carries { reqId: 'r1' } under LOG_CONTEXT
328
+
329
+ // Later, at the catch site, logging the error reattaches that context:
330
+ log.error('caught', err) // record includes reqId: 'r1'
331
+ ```
332
+
333
+ > Note: only **promise rejections** are tagged. A **synchronous** throw out of `logWith`
334
+ > is re‑thrown unchanged (not tagged) — make `inner` async if you want the tag.
335
+
336
+ `LOG_CONTEXT` is exported (`Symbol.for('@ayepi/log:ctx')`, stable across bundles) so you
337
+ can read it off an error directly: `(err as Record<symbol, unknown>)[LOG_CONTEXT]`.
338
+
339
+ `getContext()` is also exported — it returns the **mutable** current store object
340
+ (`Record<string, unknown>`), whereas `context()` returns a frozen snapshot. Prefer
341
+ `context()` in application code.
342
+
343
+ ---
344
+
345
+ ## Output & formatting
346
+
347
+ Two formats, chosen by `structured`:
348
+
349
+ - **text** (default): `[tms] level msg key=value, key=value` with a trailing
350
+ `error=Name: message` and `(+N more)` for additional errors.
351
+ ```
352
+ [1700000000000] info hello a=1, b=x
353
+ [1700000000000] error boom error=Error: nope
354
+ ```
355
+ - **JSON** (`structured: true`): one stable JSON object per line. `undefined` values are
356
+ dropped and residual cycles become `"[Circular]"`.
357
+
358
+ `timestamp: 'epoch'` makes `tms` a number (`Date.now()`); the default `'iso'` makes it
359
+ `new Date(now()).toISOString()`.
360
+
361
+ ### `filter` hook
362
+
363
+ Runs on the built record just before formatting (and after threshold + record build).
364
+ Return a (possibly modified) record to keep it, or `null` / `undefined` to drop the log
365
+ entirely. Use it to redact or enrich:
366
+
367
+ ```ts
368
+ createLogger({
369
+ filter: (r) => (r.secret ? null : { ...r, ip: redact(r.ip) }),
370
+ })
371
+ ```
372
+
373
+ ---
374
+
375
+ ## Sanitization — `sanitize` / `createSanitizer`
376
+
377
+ Declarative redaction + truncation applied to every record after `filter`, for both direct
378
+ logger calls **and** intercepted `console.*` (it transforms the record before formatting, so
379
+ text and JSON output are both sanitized). Configure it on the logger, or build a standalone
380
+ transformer with `createSanitizer(opts)` (same `(record) => record | null` shape as `filter`,
381
+ so it composes there too).
382
+
383
+ ```ts
384
+ interface SanitizeOptions {
385
+ /** Drop a record entirely — return false. Runs first. */
386
+ filter?: (record: LogRecord) => boolean
387
+ /** Property names to mask — string (case-insensitive exact) or RegExp. Matched at any depth. */
388
+ sensitiveKeys?: readonly (string | RegExp)[]
389
+ /** String values to mask when they match — string (case-insensitive substring) or RegExp. */
390
+ sensitiveValues?: readonly (string | RegExp)[]
391
+ /** Turn a sensitive value into its masked form (default () => '[redacted]'; see partialMask). */
392
+ mask?: (value: unknown, key?: string) => unknown
393
+ /** Truncate strings longer than this; appends '... (+N more)'. */
394
+ maxStringLength?: number
395
+ /** Truncate a homogeneous array (all elements same kind) beyond this; appends a '(+N more)' element. */
396
+ maxArrayLength?: number
397
+ }
398
+ ```
399
+
400
+ ```ts
401
+ import { createLogger, partialMask } from '@ayepi/log'
402
+
403
+ const log = createLogger({
404
+ sanitize: {
405
+ sensitiveKeys: ['password', 'authorization', /token$/i], // → '[redacted]'
406
+ sensitiveValues: [/\b\d{16}\b/], // mask card-number-looking strings
407
+ mask: partialMask(3), // keep first 3 chars, then '***'
408
+ maxStringLength: 2000, // long blobs → 'first 2000…... (+N more)'
409
+ maxArrayLength: 100, // big homogeneous arrays → first 100 + '(+N more)'
410
+ },
411
+ })
412
+ ```
413
+
414
+ - The sanitizer walks **plain** objects and arrays; the reserved `tms` / `level` fields are
415
+ kept pristine. (In the pipeline, values are already resolved to plain shapes by the
416
+ `toLOG`/`toJSON` pass above before `sanitize` runs, so `Date`s arrive as strings, etc.)
417
+ - `partialMask(keep = 0, fill = '***')` is the bundled helper (`partialMask(3)('secret') === 'sec***'`;
418
+ values no longer than `keep`, and the default `keep` of 0, mask fully).
419
+ - Cycles / shared references are left as the original ref (the formatter handles them).
420
+
421
+ ## Deferred arguments — `logMaybe`
422
+
423
+ `logMaybe(fn)` wraps an expensive argument so `fn` runs **only when the line is actually
424
+ logged** (its level passes the threshold). Under console interception the structured pipeline
425
+ calls `fn(level)` and awaits it, then treats the result as a normal argument (an object is
426
+ merged, a string joins `msg`, an `Error` becomes `record.error`). A line below the threshold
427
+ never invokes `fn` at all.
428
+
429
+ ```ts
430
+ import { logMaybe } from '@ayepi/log'
431
+
432
+ log.debug('state', logMaybe(() => buildExpensiveSnapshot())) // snapshot built only at debug level
433
+ log.info('user', logMaybe(async (lvl) => loadProfile(lvl))) // async is awaited before the line is written
434
+ ```
435
+
436
+ ```ts
437
+ function logMaybe(fn: (level: Level) => MaybePromise<unknown>): LazyLogValue
438
+ ```
439
+
440
+ - A line containing a top-level `logMaybe` is delivered **asynchronously** (the value is
441
+ awaited first). Nested `logMaybe` values aren't resolved — they render via `toJSON`.
442
+ - On the **non-intercepted** path the returned value has a `toJSON` (and Node inspect) that
443
+ renders the synchronous value, or `'(unresolved value)'` when `fn` returns a promise.
444
+ - If `fn` throws / rejects, the argument becomes `'(unresolved value)'` and the error is
445
+ routed to `onError`.
446
+
447
+ ---
448
+
449
+ ## Gotchas (overview‑level)
450
+
451
+ - **Bare import is side‑effect‑free.** Nothing touches `console` until you opt in.
452
+ - **Synchronous throws aren't tagged** with trace context — only promise rejections out of
453
+ `logWith` get the `LOG_CONTEXT` tag.
454
+ - **Threshold drops happen early.** A call below the level threshold never builds a record
455
+ or runs `filter`, so don't rely on side effects in argument expressions — wrap an expensive
456
+ argument in `logMaybe(fn)` to compute it only when the line will be logged.
457
+ - **One shared trace store.** Every logger instance shares the package's global
458
+ `AsyncLocalStorage`; you can't get two fully isolated context stores by creating two
459
+ loggers. (More in `ayepi-log-middleware.md`.)
460
+
461
+ For transport‑, error‑, console‑, and middleware‑specific gotchas, see the topic files.
462
+
463
+ ---
464
+
465
+ ## Related docs
466
+
467
+ - `ayepi-log-transports.md`, `ayepi-log-errors-console.md`, `ayepi-log-middleware.md`
468
+ (this doc set).
469
+ - `ayepi-core.md` — `spec()`, `implement()`, `server()`.
470
+ - `ayepi-core-middleware.md` — `middleware()`, stacks, `.group()` / `.endpoint()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/log",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Structured logging for ayepi — AsyncLocalStorage trace context, console interception, console/file transports, error serialization, middleware",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -18,7 +18,8 @@
18
18
  "type": "module",
19
19
  "sideEffects": false,
20
20
  "files": [
21
- "dist"
21
+ "dist",
22
+ "ayepi-*.md"
22
23
  ],
23
24
  "exports": {
24
25
  ".": {
@@ -67,7 +68,7 @@
67
68
  "node": ">=18"
68
69
  },
69
70
  "peerDependencies": {
70
- "@ayepi/core": "^0.1.0"
71
+ "@ayepi/core": "^0.2.0"
71
72
  },
72
73
  "peerDependenciesMeta": {
73
74
  "@ayepi/core": {
@@ -80,7 +81,7 @@
80
81
  "tsdown": "^0.12.0",
81
82
  "vitest": "^2.1.8",
82
83
  "zod": "^4.4.3",
83
- "@ayepi/core": "0.1.0"
84
+ "@ayepi/core": "0.2.0"
84
85
  },
85
86
  "keywords": [
86
87
  "ayepi",