@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 +1 -1
- package/ayepi-log-errors-console.md +171 -0
- package/ayepi-log-middleware.md +238 -0
- package/ayepi-log-transports.md +205 -0
- package/ayepi-log.md +470 -0
- package/package.json +5 -4
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
|
|
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.
|
|
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.
|
|
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.
|
|
84
|
+
"@ayepi/core": "0.2.0"
|
|
84
85
|
},
|
|
85
86
|
"keywords": [
|
|
86
87
|
"ayepi",
|