@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.
Files changed (2) hide show
  1. package/README.md +15 -737
  2. 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 and Go, with a private Go
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
- ## 1. Purpose
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
- 14:32:18.417 [discord-bot] [music] INFO queue drained count=12 duration_ms=340
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
- A missing `service` or `scope` drops its bracket rather than leaving an
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
- log.error('failed to fetch user', {
461
- error,
462
- operation: 'fetch_user',
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
- ### Configuration
20
+ const log = createLogger({ service: "api", scope: "queue" });
525
21
 
526
- Both server runtimes resolve every setting in the same order:
22
+ log.info("queue drained", { count: 12, duration_ms: 340 });
527
23
 
528
- ```text
529
- explicit option → environment variable → runtime detection → package default
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
- Sampling hashes the active trace ID (falling back to the request ID) with
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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekkolyth/logging",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Zero-dependency structured logging for JavaScript and TypeScript, with context propagation and HTTP correlation.",
5
5
  "keywords": [
6
6
  "logging",