@ekkolyth/logging 0.1.0 → 0.1.1
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 +15 -737
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,751 +1,29 @@
|
|
|
1
1
|
# @ekkolyth/logging
|
|
2
2
|
|
|
3
|
-
Structured logging for JavaScript/TypeScript
|
|
4
|
-
companion module sharing the same semantics. The published package is
|
|
5
|
-
`@ekkolyth/logging`; the Go module is source-private and lives alongside it in
|
|
6
|
-
the same repository.
|
|
3
|
+
Structured logging for JavaScript/TypeScript.
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
`@ekkolyth/logging` gives a service one thing to reach for when it needs to emit
|
|
11
|
-
a log line, propagate request context, or wrap an HTTP boundary. It replaces
|
|
12
|
-
a wrapper around a third-party logging library with an owned engine: no
|
|
13
|
-
runtime dependencies, no borrowed formatting, no surface the maintainer
|
|
14
|
-
didn't design.
|
|
15
|
-
|
|
16
|
-
The JavaScript package targets Node ≥20, Bun, and browsers. The Go module
|
|
17
|
-
targets `log/slog`. Both produce the same semantic log record — same field
|
|
18
|
-
names, same defaults, same failure behavior — while keeping each runtime's
|
|
19
|
-
own idiom for calling the logger.
|
|
20
|
-
|
|
21
|
-
## 2. Design mindset
|
|
22
|
-
|
|
23
|
-
Three decisions shape everything else in this package:
|
|
24
|
-
|
|
25
|
-
- **Own the engine, don't wrap one.** JavaScript's formatter, redactor, and
|
|
26
|
-
serializer are all first-party code with zero runtime dependencies. Go
|
|
27
|
-
builds directly on the standard library's `log/slog.Handler` interface
|
|
28
|
-
rather than introducing a second logging abstraction next to it.
|
|
29
|
-
- **Total normalization.** No supported logging call fails because a value
|
|
30
|
-
can't be represented. A cyclic object, a throwing getter, a stray
|
|
31
|
-
`Symbol`, or a `slog.LogValuer` that panics all resolve to an explicit
|
|
32
|
-
marker instead of crashing the caller. A logging library that can throw
|
|
33
|
-
from inside a call site defeats the reason it exists.
|
|
34
|
-
- **Validated boundaries, minimal surface.** Each entry point exports
|
|
35
|
-
exactly what it needs to and nothing accumulated along the way. Enum and
|
|
36
|
-
environment misconfiguration fails loudly and early, at construction time
|
|
37
|
-
— a `TypeError` in JavaScript, a panic in Go — because that failure is
|
|
38
|
-
cheap to see in development and expensive to discover in production.
|
|
39
|
-
Runtime record and output failures never do this; they degrade to an
|
|
40
|
-
internal-error path instead (see §6).
|
|
41
|
-
|
|
42
|
-
## 3. The React-to-React-Native semantic-parity model
|
|
43
|
-
|
|
44
|
-
JavaScript and Go are not the same language, and this package doesn't
|
|
45
|
-
pretend they are. The model is closer to React and React Native: the same
|
|
46
|
-
concepts — components, props, a render cycle — expressed through each
|
|
47
|
-
platform's native idiom (JSX and the DOM vs. native views). A developer who
|
|
48
|
-
knows one can read the other without relearning what a "component" is, but
|
|
49
|
-
never expects the call syntax to match.
|
|
50
|
-
|
|
51
|
-
`@ekkolyth/logging` and its Go companion apply the same idea to logging:
|
|
52
|
-
|
|
53
|
-
| Concept | Shared semantics | JavaScript idiom | Go idiom |
|
|
54
|
-
|---|---|---|---|
|
|
55
|
-
| Emit a record | same field names, same precedence, same failure behavior | `log.info(msg, attrs)` — message first, attributes second | `log.Info(msg, "key", value, ...)` — native `slog` variadic pairs |
|
|
56
|
-
| Bind attributes | new logger, same emission rules | `log.with(bindings)` returns a new `Logger` | `log.With(...)` returns a new `*slog.Logger` |
|
|
57
|
-
| Configure | same options, same env vars, same resolution order | `createLogger(options)` throws `TypeError` on bad input | `logger.New(options)` panics naming the bad field |
|
|
58
|
-
| Propagate context | same merge rules, root logger picks it up automatically | `AsyncLocalStorage` under `runWithContext` | `context.Context` under `logcontext.With` |
|
|
59
|
-
| Wrap HTTP | same correlation, same level policy, same sampling | Web `Request`/`Response` wrappers | `http.Handler`/`http.RoundTripper` wrappers |
|
|
60
|
-
|
|
61
|
-
What is **intentionally** different: JavaScript accepts an attributes object
|
|
62
|
-
because that is how the language expresses a record; Go accepts variadic
|
|
63
|
-
key-value pairs because that is `slog`'s native argument shape. Go returns
|
|
64
|
-
`*slog.Logger` so `With`, `Enabled`, and the `*Context` methods keep their
|
|
65
|
-
standard-library behavior; JavaScript returns a purpose-built `Logger`
|
|
66
|
-
because there is no standard-library equivalent to preserve. Neither side
|
|
67
|
-
apologizes for reading like the language it's written in — a Go caller
|
|
68
|
-
should never have to squint at a JavaScript-shaped API, and vice versa.
|
|
69
|
-
|
|
70
|
-
## 4. Package anatomy
|
|
71
|
-
|
|
72
|
-
Three JavaScript entry points, and a Go module with two subpackages that
|
|
73
|
-
mirror them:
|
|
74
|
-
|
|
75
|
-
```text
|
|
76
|
-
@ekkolyth/logging core: levels, records, redaction, formatting, output
|
|
77
|
-
@ekkolyth/logging/context transport of immutable bindings
|
|
78
|
-
@ekkolyth/logging/http correlation-header handling, HTTP boundary records
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
```text
|
|
82
|
-
github.com/ekkolyth/ekko-os/packages/logging/go package logger
|
|
83
|
-
github.com/ekkolyth/ekko-os/packages/logging/go/context package logcontext
|
|
84
|
-
github.com/ekkolyth/ekko-os/packages/logging/go/http package loghttp
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
The dependency direction is one-way: HTTP may consume context, context knows
|
|
88
|
-
nothing about HTTP, and none of the three layers imports anything outside
|
|
89
|
-
the package. In particular, no layer imports this repository's Ekko service
|
|
90
|
-
or scope catalog — that catalog is repository-only lint data (see §9), never
|
|
91
|
-
a runtime dependency of the logger itself.
|
|
92
|
-
|
|
93
|
-
## 5. Quick starts
|
|
94
|
-
|
|
95
|
-
<!-- readme-quickstart:js -->
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
import { createLogger } from '@ekkolyth/logging'
|
|
99
|
-
import { runWithContext } from '@ekkolyth/logging/context'
|
|
100
|
-
import { wrapHandler } from '@ekkolyth/logging/http'
|
|
101
|
-
|
|
102
|
-
const log = createLogger({ service: 'api', scope: 'queue' })
|
|
103
|
-
|
|
104
|
-
log.info('queue drained', { count: 12, duration_ms: 340 })
|
|
105
|
-
|
|
106
|
-
await runWithContext({ request_id: 'req_123' }, async () => {
|
|
107
|
-
log.info('processing job')
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
const handler = wrapHandler(log, async () => new Response('ok'))
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
<!-- readme-quickstart:go -->
|
|
114
|
-
|
|
115
|
-
```go
|
|
116
|
-
import (
|
|
117
|
-
"context"
|
|
118
|
-
|
|
119
|
-
logger "github.com/ekkolyth/ekko-os/packages/logging/go"
|
|
120
|
-
logcontext "github.com/ekkolyth/ekko-os/packages/logging/go/context"
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
log := logger.New(logger.Options{Service: "api", Scope: "queue"})
|
|
124
|
-
|
|
125
|
-
log.Info("queue drained", "count", 12, "duration_ms", 340)
|
|
126
|
-
|
|
127
|
-
ctx := logcontext.With(context.Background(), "request_id", "req_123")
|
|
128
|
-
log.InfoContext(ctx, "processing job")
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
### Core public API
|
|
132
|
-
|
|
133
|
-
```ts
|
|
134
|
-
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'
|
|
135
|
-
export type LogFormat = 'auto' | 'json' | 'pretty'
|
|
136
|
-
export type LogAttributes = Record<string, unknown>
|
|
137
|
-
export type LogOutput = (line: string) => void
|
|
138
|
-
|
|
139
|
-
export interface LoggerOptions {
|
|
140
|
-
service?: string
|
|
141
|
-
scope?: string
|
|
142
|
-
level?: LogLevel
|
|
143
|
-
format?: LogFormat
|
|
144
|
-
colors?: 'auto' | boolean
|
|
145
|
-
output?: LogOutput
|
|
146
|
-
bindings?: LogAttributes
|
|
147
|
-
redact?: readonly string[]
|
|
148
|
-
onInternalError?: (error: unknown) => void
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export interface Logger {
|
|
152
|
-
with(bindings: LogAttributes): Logger
|
|
153
|
-
enabled(level: Exclude<LogLevel, 'silent'>): boolean
|
|
154
|
-
debug(message: string, attributes?: LogAttributes): void
|
|
155
|
-
info(message: string, attributes?: LogAttributes): void
|
|
156
|
-
warn(message: string, attributes?: LogAttributes): void
|
|
157
|
-
error(message: string, attributes?: LogAttributes): void
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export function createLogger(options?: LoggerOptions): Logger
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
`silent` is a threshold value, not an emission method — there is no
|
|
164
|
-
`log.silent(...)`. `with` never mutates its parent; it returns an
|
|
165
|
-
independent `Logger` that layers its own bindings on top. Every emission
|
|
166
|
-
method takes the message first and an optional attributes object second;
|
|
167
|
-
there is no attributes-first overload.
|
|
168
|
-
|
|
169
|
-
```go
|
|
170
|
-
type Level uint8
|
|
171
|
-
|
|
172
|
-
const (
|
|
173
|
-
LevelDefault Level = iota
|
|
174
|
-
LevelDebug
|
|
175
|
-
LevelInfo
|
|
176
|
-
LevelWarn
|
|
177
|
-
LevelError
|
|
178
|
-
LevelSilent
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
type Format uint8
|
|
182
|
-
|
|
183
|
-
const (
|
|
184
|
-
FormatAuto Format = iota
|
|
185
|
-
FormatJSON
|
|
186
|
-
FormatPretty
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
type ColorMode uint8
|
|
190
|
-
|
|
191
|
-
const (
|
|
192
|
-
ColorsAuto ColorMode = iota
|
|
193
|
-
ColorsAlways
|
|
194
|
-
ColorsNever
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
type Options struct {
|
|
198
|
-
Service string
|
|
199
|
-
Scope string
|
|
200
|
-
Level Level
|
|
201
|
-
Format Format
|
|
202
|
-
Colors ColorMode
|
|
203
|
-
Output io.Writer
|
|
204
|
-
Bindings []slog.Attr
|
|
205
|
-
Redact []string
|
|
206
|
-
OnInternalError func(error)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
func New(options Options) *slog.Logger
|
|
210
|
-
func NewHandler(options Options) slog.Handler
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
`New` returns a real `*slog.Logger`, so `With`, `Enabled`, the level
|
|
214
|
-
methods, and the `*Context` variants all behave exactly as the standard
|
|
215
|
-
library documents them. `NewHandler` exists for composition — wrapping in
|
|
216
|
-
another `slog.Handler`, or installing with `slog.SetDefault` — and
|
|
217
|
-
implements `Enabled`, `Handle`, `WithAttrs`, and `WithGroup` correctly. A
|
|
218
|
-
nil `Output` means `os.Stdout`.
|
|
219
|
-
|
|
220
|
-
`slog.Logger` also exposes `Log` and `LogAttrs` at arbitrary numeric
|
|
221
|
-
levels; the output contract still has four buckets. Anything below
|
|
222
|
-
`slog.LevelInfo` renders as `debug`, `[Info, Warn)` as `info`, `[Warn,
|
|
223
|
-
Error)` as `warn`, and `Error` and above as `error`. A custom numeric level
|
|
224
|
-
never produces an output-level name outside those four.
|
|
225
|
-
|
|
226
|
-
### Context public API
|
|
227
|
-
|
|
228
|
-
```ts
|
|
229
|
-
export function runWithContext<T>(
|
|
230
|
-
bindings: LogAttributes,
|
|
231
|
-
callback: () => T | Promise<T>,
|
|
232
|
-
): T | Promise<T>
|
|
233
|
-
|
|
234
|
-
export function currentContext(): Readonly<LogAttributes> | undefined
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
The Node/Bun implementation uses `AsyncLocalStorage`: bindings are copied,
|
|
238
|
-
nested calls merge onto the parent, concurrent call stacks stay isolated,
|
|
239
|
-
and the previous context is restored after the callback settles either way.
|
|
240
|
-
A root `Logger` merges the active context into every record automatically
|
|
241
|
-
— nothing has to read `currentContext()` by hand at each call site.
|
|
242
|
-
|
|
243
|
-
The browser build exports the same two names, and each one throws a clear
|
|
244
|
-
unsupported-runtime error instead of silently running the callback with no
|
|
245
|
-
context. Browser code binds request-scoped data explicitly with
|
|
246
|
-
`log.with(bindings)` instead.
|
|
247
|
-
|
|
248
|
-
```go
|
|
249
|
-
func With(ctx context.Context, attrs ...any) context.Context
|
|
250
|
-
func Attrs(ctx context.Context) []slog.Attr
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
`With` uses `slog`'s own argument conversion, copies whatever bindings
|
|
254
|
-
already exist on `ctx`, and returns a child context carrying the merge.
|
|
255
|
-
`Attrs` returns a copy, never the stored slice. The root handler merges
|
|
256
|
-
these bindings only into `*Context` calls (`InfoContext`, `ErrorContext`,
|
|
257
|
-
…) — a plain `Info`/`Error` call passes no context and picks up nothing.
|
|
258
|
-
|
|
259
|
-
The context module is a transport for arbitrary bindings. It has no
|
|
260
|
-
opinion about HTTP, doesn't generate request IDs, and doesn't parse
|
|
261
|
-
headers — that's §5's HTTP module. Correlation IDs are ordinary bindings as
|
|
262
|
-
far as this module is concerned.
|
|
263
|
-
|
|
264
|
-
### HTTP public API
|
|
265
|
-
|
|
266
|
-
```ts
|
|
267
|
-
type RequestHandler = (request: Request) => Response | Promise<Response>
|
|
268
|
-
type FetchLike = (
|
|
269
|
-
input: RequestInfo | URL,
|
|
270
|
-
init?: RequestInit,
|
|
271
|
-
) => Promise<Response>
|
|
272
|
-
|
|
273
|
-
interface HandlerOptions {
|
|
274
|
-
sampleRate?: number
|
|
275
|
-
slowThresholdMs?: number
|
|
276
|
-
route?: (request: Request) => string | undefined
|
|
277
|
-
clientIp?: (request: Request) => string | undefined
|
|
278
|
-
exclude?: (request: Request) => boolean
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
interface FetchOptions {
|
|
282
|
-
provider?: string
|
|
283
|
-
sampleRate?: number
|
|
284
|
-
slowThresholdMs?: number
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
export function wrapHandler(
|
|
288
|
-
log: Logger,
|
|
289
|
-
handler: RequestHandler,
|
|
290
|
-
options?: HandlerOptions,
|
|
291
|
-
): RequestHandler
|
|
292
|
-
|
|
293
|
-
export function wrapFetch(
|
|
294
|
-
log: Logger,
|
|
295
|
-
fetch: FetchLike,
|
|
296
|
-
options?: FetchOptions,
|
|
297
|
-
): FetchLike
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
`FetchLike` is a **structural** shape, not `typeof globalThis.fetch`
|
|
301
|
-
verbatim — the ambient `fetch` type on some runtimes (Bun's included) also
|
|
302
|
-
carries non-standard static members such as `fetch.preconnect`, which a
|
|
303
|
-
plain function value passed in a test or a wrapped client doesn't have.
|
|
304
|
-
Any real `fetch` implementation still satisfies the narrower shape used
|
|
305
|
-
here.
|
|
306
|
-
|
|
307
|
-
The browser build supports `wrapFetch` fully — it derives correlation from
|
|
308
|
-
explicit request headers and from the bound attributes already on the
|
|
309
|
-
`Logger` passed in, so it needs no ambient context. `wrapHandler` throws
|
|
310
|
-
the same unsupported-runtime error as browser context, and it throws at
|
|
311
|
-
wrap time rather than at request time: concurrent request context has no
|
|
312
|
-
platform adapter in a browser, so there is no safe way to implement it
|
|
313
|
-
there.
|
|
314
|
-
|
|
315
|
-
```go
|
|
316
|
-
type HandlerOptions struct {
|
|
317
|
-
SampleRate float64
|
|
318
|
-
SlowThreshold time.Duration
|
|
319
|
-
Route func(*http.Request) string
|
|
320
|
-
ClientIP func(*http.Request) string
|
|
321
|
-
Exclude func(*http.Request) bool
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
type TransportOptions struct {
|
|
325
|
-
Provider string
|
|
326
|
-
SampleRate float64
|
|
327
|
-
SlowThreshold time.Duration
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
func WrapHandler(
|
|
331
|
-
log *slog.Logger,
|
|
332
|
-
next http.Handler,
|
|
333
|
-
options HandlerOptions,
|
|
334
|
-
) http.Handler
|
|
335
|
-
|
|
336
|
-
func WrapTransport(
|
|
337
|
-
log *slog.Logger,
|
|
338
|
-
base http.RoundTripper,
|
|
339
|
-
options TransportOptions,
|
|
340
|
-
) http.RoundTripper
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
A nil `log` uses `slog.Default()`; a nil `base` transport uses
|
|
344
|
-
`http.DefaultTransport`. A zero `SampleRate` means the documented default
|
|
345
|
-
of `1.0`; an explicit rate must fall within `(0, 1]`. A zero
|
|
346
|
-
`SlowThreshold` means one second; a negative one is invalid. JavaScript
|
|
347
|
-
uses `undefined` for the same two defaults and the same `(0, 1]` bound.
|
|
348
|
-
|
|
349
|
-
`exclude`/`Exclude` only suppresses the completion record for that
|
|
350
|
-
request — correlation, context binding, and the echoed request ID on the
|
|
351
|
-
response all still apply. This is a generic policy hook: an application
|
|
352
|
-
that only wants to exclude its own health-check route supplies that
|
|
353
|
-
decision at boot rather than the package hardcoding a path prefix.
|
|
354
|
-
|
|
355
|
-
## 6. Behavioral contracts
|
|
356
|
-
|
|
357
|
-
### Records
|
|
358
|
-
|
|
359
|
-
Every emitted line has this semantic shape:
|
|
360
|
-
|
|
361
|
-
```json
|
|
362
|
-
{
|
|
363
|
-
"time": "2026-08-21T14:32:18.417Z",
|
|
364
|
-
"level": "info",
|
|
365
|
-
"msg": "queue drained",
|
|
366
|
-
"service": "discord-bot",
|
|
367
|
-
"scope": "music",
|
|
368
|
-
"count": 12,
|
|
369
|
-
"duration_ms": 340
|
|
370
|
-
}
|
|
371
|
-
```
|
|
372
|
-
|
|
373
|
-
- `time` is UTC RFC 3339 with millisecond precision.
|
|
374
|
-
- `level` is lowercase `debug`, `info`, `warn`, or `error` — never `silent`.
|
|
375
|
-
- `msg` is the caller's stable event description; variable data belongs in
|
|
376
|
-
attributes, not interpolated into the message.
|
|
377
|
-
- `service` and `scope` appear only when non-empty.
|
|
378
|
-
- `time`, `level`, and `msg` are package-owned and cannot be overridden by
|
|
379
|
-
a caller-supplied attribute of the same name.
|
|
380
|
-
- Precedence, low to high: logger bindings, then active context, then
|
|
381
|
-
call-site attributes, then the three package-owned fields above.
|
|
382
|
-
Duplicate keys resolve last-write-wins under that ordering.
|
|
383
|
-
- Canonical fields render first, in the order shown; remaining keys render
|
|
384
|
-
in ascending Unicode code-point order. Field order is not a semantic
|
|
385
|
-
guarantee, but it is deterministic — this is what makes byte-for-byte
|
|
386
|
-
pretty-output comparison between the two runtimes possible.
|
|
387
|
-
- Ekko call sites use flat `snake_case` attribute names by repository
|
|
388
|
-
convention; the runtime itself preserves whatever keys a caller passes.
|
|
389
|
-
- Every formatter writes exactly one physical line with one trailing
|
|
390
|
-
newline.
|
|
391
|
-
|
|
392
|
-
Pretty output is the same record for a human:
|
|
5
|
+
Entry points:
|
|
393
6
|
|
|
394
7
|
```text
|
|
395
|
-
|
|
8
|
+
@ekkolyth/logging core: levels, records, redaction, formatting
|
|
9
|
+
@ekkolyth/logging/context request-scoped bindings
|
|
10
|
+
@ekkolyth/logging/http HTTP wrappers + correlation
|
|
396
11
|
```
|
|
397
12
|
|
|
398
|
-
|
|
399
|
-
empty one. Levels are padded to five characters. The clock is UTC
|
|
400
|
-
`HH:mm:ss.SSS`. A scalar renders bare only when it's safe to; anything
|
|
401
|
-
empty, or containing whitespace, quotes, backslashes, or control
|
|
402
|
-
characters, gets JSON-string-escaped instead — a literal newline or escape
|
|
403
|
-
byte inside a value can never split the physical line or smuggle in an
|
|
404
|
-
unowned ANSI sequence. Removing ANSI bytes from a colored line always
|
|
405
|
-
yields the exact uncolored line; color decorates existing segments and
|
|
406
|
-
never changes their content.
|
|
407
|
-
|
|
408
|
-
### Safe normalization and limits
|
|
409
|
-
|
|
410
|
-
No supported logging call fails because a value can't be represented:
|
|
411
|
-
|
|
412
|
-
- `Date` (JavaScript) and `time.Time` (Go) normalize to UTC RFC 3339.
|
|
413
|
-
- A `slog.LogValuer` resolves with panic recovery and the standard
|
|
414
|
-
100-step ceiling; a panic or an exhausted chain becomes
|
|
415
|
-
`[Unserializable]`.
|
|
416
|
-
- `bigint` normalizes to a decimal string.
|
|
417
|
-
- Non-finite floats normalize to the strings `NaN`, `Infinity`, or
|
|
418
|
-
`-Infinity`.
|
|
419
|
-
- A cyclic reference normalizes to `[Circular]`.
|
|
420
|
-
- Anything past a recursion depth of eight normalizes to `[MaxDepth]`.
|
|
421
|
-
- Strings are capped at 16 KiB of UTF-8 and end in `[Truncated]` when cut,
|
|
422
|
-
never mid-way through a multi-byte sequence.
|
|
423
|
-
- Arrays and slices keep their first 100 normalized entries and append a
|
|
424
|
-
`[Truncated: N items]` marker for the rest.
|
|
425
|
-
- Objects and maps keep the first 100 keys, in the same deterministic
|
|
426
|
-
order as everything else, and add `_logging_truncated` with the omitted
|
|
427
|
-
count — a caller-supplied key of that exact name is overwritten by the
|
|
428
|
-
package's own truncation marker.
|
|
429
|
-
- A throwing getter becomes `[Unserializable]` for that one property.
|
|
430
|
-
- Functions, symbols, channels, complex numbers, unsafe pointers, and
|
|
431
|
-
other unrepresentable runtime values become `[Unsupported: type]`.
|
|
432
|
-
|
|
433
|
-
These are fixed v1 safety limits, not public configuration.
|
|
434
|
-
|
|
435
|
-
### Redaction
|
|
436
|
-
|
|
437
|
-
Redaction runs recursively, after normalization and before formatting or
|
|
438
|
-
output. Key matching lowercases and strips non-alphanumeric separators —
|
|
439
|
-
`api_key`, `API-KEY`, and `apiKey` are the same key, while `token_count`
|
|
440
|
-
does not match `token`. The non-disableable default set:
|
|
441
|
-
|
|
442
|
-
```text
|
|
443
|
-
password passwd secret token authorization proxy_authorization
|
|
444
|
-
cookie set_cookie api_key access_token refresh_token private_key
|
|
445
|
-
client_secret
|
|
446
|
-
```
|
|
447
|
-
|
|
448
|
-
`redact` adds exact keys under the same normalization; it can only extend
|
|
449
|
-
the set, never remove from it. A matched value becomes `[REDACTED]`,
|
|
450
|
-
including inside nested objects and structured errors. HTTP logging
|
|
451
|
-
separately never collects bodies, query values, credentials embedded in a
|
|
452
|
-
URL, authorization headers, or cookies in the first place — redaction is
|
|
453
|
-
the second line of defense, not the only one. It does not scan an ordinary
|
|
454
|
-
message string for secrets that were interpolated into it; keep variable
|
|
455
|
-
data in attributes.
|
|
456
|
-
|
|
457
|
-
### Errors
|
|
13
|
+
## Quick start
|
|
458
14
|
|
|
459
15
|
```ts
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
user_id: userId,
|
|
464
|
-
})
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
```go
|
|
468
|
-
log.Error(
|
|
469
|
-
"failed to fetch user",
|
|
470
|
-
"error", err,
|
|
471
|
-
"operation", "fetch_user",
|
|
472
|
-
"user_id", userID,
|
|
473
|
-
)
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
There is no required error class or interface. A native error normalizes
|
|
477
|
-
to an object carrying `type` and `message` always, plus `code`, `stack`,
|
|
478
|
-
a recursive `cause`, and `causes` (JavaScript `AggregateError`, Go
|
|
479
|
-
`Unwrap() []error`) when present. JavaScript's `type` is the error's
|
|
480
|
-
`name`, falling back to its constructor name; Go's `type` is
|
|
481
|
-
`reflect.TypeOf(err).String()`. A non-error value passed on the `error`
|
|
482
|
-
key still normalizes safely, carrying its runtime type and string form.
|
|
483
|
-
Arbitrary enumerable properties on an error are not copied — put anything
|
|
484
|
-
else worth keeping in an explicit attribute. `err` has no special meaning
|
|
485
|
-
and is not a supported alias for `error`.
|
|
486
|
-
|
|
487
|
-
The severity rule this package assumes:
|
|
488
|
-
|
|
489
|
-
- An expected outcome is a value, logged at `info` or not at all.
|
|
490
|
-
- A recoverable anomaly is `warn`.
|
|
491
|
-
- An unexpected, actionable failure is `error`.
|
|
492
|
-
- A catch that rethrows or wraps doesn't log at that site — the eventual
|
|
493
|
-
catch that actually handles it logs once, there.
|
|
494
|
-
- A top-level boundary logs an otherwise-unhandled failure exactly once
|
|
495
|
-
before letting the platform's normal control flow continue (rethrow,
|
|
496
|
-
re-panic, or an unhandled-rejection handler).
|
|
497
|
-
|
|
498
|
-
`operation`, `error_code`, and `retryable` are optional cross-runtime
|
|
499
|
-
contextual attribute names. The logger never infers them and never changes
|
|
500
|
-
severity based on them.
|
|
501
|
-
|
|
502
|
-
### Internal logger failures
|
|
503
|
-
|
|
504
|
-
A serialization, redaction, formatting, or output failure never replaces
|
|
505
|
-
the application's own log call with an exception. The logger calls
|
|
506
|
-
`onInternalError`/`OnInternalError` when one is configured; otherwise it
|
|
507
|
-
makes one best-effort diagnostic write to `stderr`. That emergency write
|
|
508
|
-
never passes back through a logger, so it cannot recurse. If the callback
|
|
509
|
-
itself throws or panics, or the `stderr` write also fails, the logger
|
|
510
|
-
recovers silently and gives up — an internal failure is never allowed to
|
|
511
|
-
crash the caller.
|
|
512
|
-
|
|
513
|
-
On the Go side, calling `Handle` directly also returns the underlying
|
|
514
|
-
write error after the internal-error path has already run, so a caller
|
|
515
|
-
that owns a raw `slog.Handler` can still see it. A call through
|
|
516
|
-
`slog.Logger` keeps `slog`'s own behavior and does not surface that return
|
|
517
|
-
value.
|
|
518
|
-
|
|
519
|
-
Unknown enum values and invalid environment values are a different kind of
|
|
520
|
-
failure — a configuration mistake, not a runtime one — and are reported
|
|
521
|
-
immediately: JavaScript throws `TypeError` inside `createLogger`, Go
|
|
522
|
-
panics inside `New`/`NewHandler` naming the bad setting.
|
|
16
|
+
import { createLogger } from "@ekkolyth/logging";
|
|
17
|
+
import { runWithContext } from "@ekkolyth/logging/context";
|
|
18
|
+
import { wrapHandler } from "@ekkolyth/logging/http";
|
|
523
19
|
|
|
524
|
-
|
|
20
|
+
const log = createLogger({ service: "api", scope: "queue" });
|
|
525
21
|
|
|
526
|
-
|
|
22
|
+
log.info("queue drained", { count: 12, duration_ms: 340 });
|
|
527
23
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
The only environment variables the package reads:
|
|
533
|
-
|
|
534
|
-
| Variable | Effect |
|
|
535
|
-
|---|---|
|
|
536
|
-
| `LOG_LEVEL` | `debug` \| `info` \| `warn` \| `error` \| `silent` |
|
|
537
|
-
| `LOG_FORMAT` | `auto` \| `json` \| `pretty` |
|
|
538
|
-
| `NO_COLOR` | presence disables ANSI, unless `colors`/`Colors` is explicit |
|
|
539
|
-
| `FORCE_COLOR` | any value other than `0` enables ANSI (`0` disables it), unless `colors`/`Colors` or `NO_COLOR` outranks it |
|
|
540
|
-
|
|
541
|
-
`auto` format selects pretty output on a TTY and JSON otherwise. JSON never
|
|
542
|
-
contains ANSI. `colors` changes only how pretty output is rendered; it
|
|
543
|
-
never changes which format got selected. The browser build never reads
|
|
544
|
-
`process.env` — `auto` there always means uncolored pretty output. A
|
|
545
|
-
JavaScript output callback's return value is ignored, including a
|
|
546
|
-
returned `Promise` — the output contract stays synchronous regardless.
|
|
547
|
-
|
|
548
|
-
`LOG_JSON`, `LOG_COLORS`, `forceJson`, and `forceColors` do not exist on
|
|
549
|
-
this package. There is no file-output option (`LOG_SOURCE`,
|
|
550
|
-
`LOG_FILE_MAP`, or a Go `LogOutput` field) — the package owns `stdout` and
|
|
551
|
-
whatever synchronous output a caller supplies, nothing else. An
|
|
552
|
-
application that needs a file writes to it itself and passes an explicit
|
|
553
|
-
output (JavaScript) or an `io.MultiWriter` (Go) — file lifecycle,
|
|
554
|
-
permissions, rotation, and shutdown stay with the application that opened
|
|
555
|
-
the file.
|
|
556
|
-
|
|
557
|
-
### Correlation
|
|
558
|
-
|
|
559
|
-
Inbound wrappers validate `traceparent` against W3C Trace Context and
|
|
560
|
-
`X-Request-Id` as 1-128 ASCII characters from `[A-Za-z0-9._:-]`; anything
|
|
561
|
-
invalid or absent is replaced. IDs are generated with Web Crypto in
|
|
562
|
-
JavaScript and `crypto/rand` in Go — no UUID dependency either side.
|
|
563
|
-
|
|
564
|
-
A valid incoming trace keeps its trace ID and flags and gets a fresh local
|
|
565
|
-
span ID. With no valid trace, the wrapper mints a version `00` traceparent
|
|
566
|
-
with a non-zero 16-byte trace ID, a non-zero 8-byte span ID, and flags
|
|
567
|
-
`00`. A valid `tracestate` header is preserved unchanged. The active log
|
|
568
|
-
context receives `request_id`, `trace_id`, and the local `span_id` so
|
|
569
|
-
every log call inside the request picks them up automatically.
|
|
570
|
-
|
|
571
|
-
The request ID is set on the Go request context before the handler runs,
|
|
572
|
-
and echoed on every JavaScript response. An outbound wrapper injects
|
|
573
|
-
`traceparent`, `tracestate`, and `X-Request-Id` only into headers the
|
|
574
|
-
caller hasn't already set — an explicit header is never overwritten, and
|
|
575
|
-
an explicit valid header seeds correlation ahead of ambient context. When
|
|
576
|
-
neither an explicit header nor ambient context supplies correlation, the
|
|
577
|
-
outbound wrapper mints a fresh root so sampling still has a stable
|
|
578
|
-
identifier. Baggage propagation is out of scope for this version.
|
|
579
|
-
|
|
580
|
-
This is propagation-grade correlation, not span recording — the package
|
|
581
|
-
exports no tracer, no span type, and no telemetry backend.
|
|
582
|
-
|
|
583
|
-
### Inbound and outbound HTTP records
|
|
584
|
-
|
|
585
|
-
Every non-excluded inbound call emits one `request completed` record with
|
|
586
|
-
`method`, `path` (query string excluded), an optional `route`, `status`,
|
|
587
|
-
`duration_ms`, `response_size_bytes` when measurable, `request_id`,
|
|
588
|
-
`trace_id`, `span_id`, an optional `ip`, an optional `user_agent`, and
|
|
589
|
-
`error` on an unhandled failure. `response_size_bytes` is
|
|
590
|
-
platform-conditional: Go always counts the bytes actually written, while
|
|
591
|
-
JavaScript can only report it when the response carries a
|
|
592
|
-
`content-length` header — the Fetch `Response` type gives no portable way
|
|
593
|
-
to measure a streamed body otherwise. JavaScript has no portable
|
|
594
|
-
client-IP API either, so `ip` is present only when a `clientIp` callback
|
|
595
|
-
supplies it; Go defaults to `RemoteAddr` with its port stripped, falling
|
|
596
|
-
back to the raw value if it can't be split.
|
|
597
|
-
|
|
598
|
-
Level and volume:
|
|
599
|
-
|
|
600
|
-
- An unhandled throw or panic is `error`, always emitted.
|
|
601
|
-
- Status `>= 500` is `error`, always emitted.
|
|
602
|
-
- Status `400`-`499` is `warn`, always emitted.
|
|
603
|
-
- A fast-enough status below `400` that's still at or above the slow
|
|
604
|
-
threshold is `warn`, always emitted.
|
|
605
|
-
- A fast, successful status is `info`, eligible for sampling.
|
|
606
|
-
|
|
607
|
-
The wrapper catches an unhandled throw or panic only long enough to emit
|
|
608
|
-
the completion record, then rethrows or re-panics the original value — it
|
|
609
|
-
never chooses or writes an application response itself.
|
|
610
|
-
|
|
611
|
-
Outbound calls follow the same shape under `outbound request completed`,
|
|
612
|
-
with `provider`, `method`, `host`, and `path` in place of the inbound
|
|
613
|
-
fields, and no request-body or query-string data ever collected either
|
|
614
|
-
direction. Failures and slow calls are never sampled away on either side
|
|
615
|
-
of the wire.
|
|
616
|
-
|
|
617
|
-
`duration_ms` truncates to a whole millisecond rather than rounding — both
|
|
618
|
-
runtimes: `Math.trunc(...)` in JavaScript, `time.Duration.Milliseconds()`
|
|
619
|
-
in Go, which is itself an integer division.
|
|
620
|
-
|
|
621
|
-
### Deterministic sampling
|
|
24
|
+
await runWithContext({ request_id: "req_123" }, async () => {
|
|
25
|
+
log.info("processing job");
|
|
26
|
+
});
|
|
622
27
|
|
|
623
|
-
|
|
624
|
-
unsigned FNV-1a, 32-bit, over its UTF-8 bytes, and retains the record when
|
|
625
|
-
`hash / 2^32 < sampleRate`. Both runtimes implement the identical
|
|
626
|
-
algorithm, so the same identifier and rate make the identical keep/drop
|
|
627
|
-
decision on either side. Every retained record below a rate of `1.0`
|
|
628
|
-
carries the exact `sample_rate` that decided it. `warn` and `error`
|
|
629
|
-
records are never sampled and never carry `sample_rate` — see the level
|
|
630
|
-
table above. The generic default is `1.0`; a lower rate is always an
|
|
631
|
-
explicit application choice.
|
|
632
|
-
|
|
633
|
-
### Quiet window and `every`
|
|
634
|
-
|
|
635
|
-
Two repeat-suppression tools sit alongside sampling:
|
|
636
|
-
|
|
637
|
-
- **Handler quiet window.** Both `wrapHandler` and `WrapHandler` drop
|
|
638
|
-
repeat fast-2xx completion records that share `method|path|status`
|
|
639
|
-
inside a window — ten minutes by default. `warn`, `error`, and slow
|
|
640
|
-
records always emit. Disable with `quietWindowMs: 0` (JavaScript) or
|
|
641
|
-
`QuietWindowOff` (Go, whose zero value selects the default instead).
|
|
642
|
-
- **`every`.** `log.every(window, ...keyParts)` in JavaScript returns a
|
|
643
|
-
rate-limited view of the logger that drops repeats of the same message
|
|
644
|
-
inside the window, keyed on service, scope, and the given parts; windows
|
|
645
|
-
accept `250ms`/`2s`/`10m`/`1h` strings or milliseconds. Go's equivalents
|
|
646
|
-
are `Every(window, keyParts...)` on the default logger, `EveryFor` on a
|
|
647
|
-
specific one, and the underlying `Throttler` for custom keys.
|
|
648
|
-
|
|
649
|
-
Suppression trades data for silence — a dropped repeat takes its count and
|
|
650
|
-
duration with it. Sampling with a stamped `sample_rate` is the
|
|
651
|
-
reconstructable alternative; the quiet window exists because a dev
|
|
652
|
-
terminal and a small log viewer are usually worth more than the lost
|
|
653
|
-
repeats.
|
|
654
|
-
|
|
655
|
-
## 7. Package-authoring blueprint
|
|
656
|
-
|
|
657
|
-
This package is meant to be the template the next owned repository package
|
|
658
|
-
follows, not a one-off. Before publishing a package this way, work through:
|
|
659
|
-
|
|
660
|
-
1. **A generic core.** No consumer-specific vocabulary — service names,
|
|
661
|
-
route prefixes, feature flags — leaks into the public API.
|
|
662
|
-
2. **Explicit exports.** Every public entry point is declared and
|
|
663
|
-
intentional; nothing is public because it happened to be reachable.
|
|
664
|
-
3. **Shared cross-runtime semantics.** If the package spans more than one
|
|
665
|
-
language, both implementations agree on the same concepts, field names,
|
|
666
|
-
defaults, and failure behavior — see §3.
|
|
667
|
-
4. **Intentional platform-native differences.** Where the runtimes
|
|
668
|
-
genuinely diverge, the divergence is a deliberate idiom choice, stated
|
|
669
|
-
in the README, not an accident of who wrote which side first.
|
|
670
|
-
5. **Validated boundaries.** Bad configuration fails immediately and
|
|
671
|
-
loudly, at the boundary where it was supplied — not three calls later
|
|
672
|
-
as a confusing runtime symptom.
|
|
673
|
-
6. **Defined failure behavior.** Every failure mode the package can hit —
|
|
674
|
-
not just the happy path — has a stated, tested outcome.
|
|
675
|
-
7. **One canonical contract.** A single source of truth for cross-runtime
|
|
676
|
-
behavior (shared fixtures, a shared schema, or an IDL) that both
|
|
677
|
-
implementations are checked against, rather than each side's tests
|
|
678
|
-
asserting only against itself.
|
|
679
|
-
8. **Generated-artifact drift checks.** Anything generated from the
|
|
680
|
-
canonical contract is checked in CI against that contract, so drift
|
|
681
|
-
fails the build instead of shipping quietly.
|
|
682
|
-
9. **Packed-consumer tests.** The exact artifact that will be published —
|
|
683
|
-
not the workspace source — is installed into a clean consumer and
|
|
684
|
-
exercised before release.
|
|
685
|
-
10. **A completed migration before public release.** Every existing
|
|
686
|
-
in-repository consumer is moved onto the new API first. A public
|
|
687
|
-
release ships with no compatibility shim and no repository code still
|
|
688
|
-
depending on the old shape.
|
|
689
|
-
|
|
690
|
-
## 8. Development and release commands
|
|
691
|
-
|
|
692
|
-
From `packages/logging/js`:
|
|
693
|
-
|
|
694
|
-
```sh
|
|
695
|
-
bun run build # tsdown → dist/
|
|
696
|
-
bun run test # build, then run the bun:test suite against dist
|
|
697
|
-
bun run typecheck # tsc --noEmit
|
|
698
|
-
bun run pack:public # stage the public-only manifest + README + LICENSE, pack a tarball
|
|
699
|
-
bun run pack:verify -- <stage-dir> <tarball> # validate the tarball in a clean Node + Bun consumer
|
|
28
|
+
const handler = wrapHandler(log, async () => new Response("ok"));
|
|
700
29
|
```
|
|
701
|
-
|
|
702
|
-
`pack:public` prints two lines — the staged directory and the tarball
|
|
703
|
-
path — which `pack:verify` takes as its two arguments. Validation installs
|
|
704
|
-
the exact tarball into throwaway Node and Bun consumers, imports every
|
|
705
|
-
entry point under both the normal and `browser` conditions, exercises one
|
|
706
|
-
real log record, confirms the browser build's unsupported operations fail
|
|
707
|
-
explicitly, and confirms every declared export target exists in the
|
|
708
|
-
archive.
|
|
709
|
-
|
|
710
|
-
From the repository root: `bun run --filter @ekkolyth/logging build` (or
|
|
711
|
-
`test`, `typecheck`) runs the same scripts through the workspace's task
|
|
712
|
-
runner.
|
|
713
|
-
|
|
714
|
-
From `packages/logging/go`:
|
|
715
|
-
|
|
716
|
-
```sh
|
|
717
|
-
go test ./... # the logging module's own suite
|
|
718
|
-
go test -race ./... # the same suite under the race detector
|
|
719
|
-
```
|
|
720
|
-
|
|
721
|
-
Releases go through Changesets: a changeset entry in `.changeset/`
|
|
722
|
-
describes the version bump, merging to the default branch opens or updates
|
|
723
|
-
a version PR, and merging that PR builds, tests, stages, and validates the
|
|
724
|
-
package before it publishes. See the repository's release workflow for the
|
|
725
|
-
exact gating — this README documents the package, not the pipeline.
|
|
726
|
-
|
|
727
|
-
## 9. Compatibility and support
|
|
728
|
-
|
|
729
|
-
- **Module format:** ESM only. There is no CommonJS build and none is
|
|
730
|
-
planned.
|
|
731
|
-
- **Runtimes:** Node ≥20 and Bun for the server entry points; any browser
|
|
732
|
-
with the Fetch API (`Request`/`Response`) and Web Crypto
|
|
733
|
-
(`crypto.getRandomValues`) for the browser condition of every entry.
|
|
734
|
-
- **Dependencies:** zero runtime dependencies. The published manifest
|
|
735
|
-
declares none, and the packed artifact is verified to contain none.
|
|
736
|
-
- **Side effects:** `sideEffects: false` — safe to tree-shake.
|
|
737
|
-
- **License:** MIT.
|
|
738
|
-
- **First public version:** `0.1.0` — the redesigned engine described in
|
|
739
|
-
this README, published with no prior public version to remain
|
|
740
|
-
compatible with.
|
|
741
|
-
- **What's out of scope for this package:** framework-specific middleware
|
|
742
|
-
(Express, Next.js, Hono, Chi, …), an OpenTelemetry SDK dependency or
|
|
743
|
-
exporter, global exception hooks or panic recovery that consumes the
|
|
744
|
-
panic, an async/buffered logging pipeline, and a public test-logger or
|
|
745
|
-
memory-sink package. A consumer that needs one of these builds it on top
|
|
746
|
-
of the seams this package already exposes — a custom `output`/`Output`,
|
|
747
|
-
or a wrapped `RequestHandler`/`http.Handler`.
|
|
748
|
-
- **Go module:** the companion Go module shares this package's semantics
|
|
749
|
-
and its behavioral contract fixtures, but is not published — it lives in
|
|
750
|
-
this repository's source tree only, consumed by this repository's own
|
|
751
|
-
services through a local module `replace`.
|