loggily 0.3.0 → 0.4.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.
Files changed (46) hide show
  1. package/README.md +67 -22
  2. package/package.json +24 -11
  3. package/src/context.ts +26 -11
  4. package/src/core.ts +118 -72
  5. package/src/file-writer.ts +12 -6
  6. package/src/index.browser.ts +9 -1
  7. package/src/index.ts +9 -1
  8. package/src/tracing.ts +11 -3
  9. package/src/worker.ts +119 -132
  10. package/.github/workflows/docs.yml +0 -58
  11. package/.github/workflows/release.yml +0 -31
  12. package/.github/workflows/test.yml +0 -20
  13. package/CHANGELOG.md +0 -45
  14. package/CLAUDE.md +0 -299
  15. package/CONTRIBUTING.md +0 -58
  16. package/benchmarks/overhead.ts +0 -267
  17. package/bun.lock +0 -479
  18. package/docs/api-reference.md +0 -400
  19. package/docs/benchmarks.md +0 -106
  20. package/docs/comparison.md +0 -315
  21. package/docs/conditional-logging-research.md +0 -159
  22. package/docs/guide.md +0 -205
  23. package/docs/migration-from-debug.md +0 -310
  24. package/docs/migration-from-pino.md +0 -178
  25. package/docs/migration-from-winston.md +0 -179
  26. package/docs/site/.vitepress/config.ts +0 -67
  27. package/docs/site/api/configuration.md +0 -94
  28. package/docs/site/api/index.md +0 -61
  29. package/docs/site/api/logger.md +0 -99
  30. package/docs/site/api/worker.md +0 -120
  31. package/docs/site/api/writers.md +0 -69
  32. package/docs/site/guide/getting-started.md +0 -143
  33. package/docs/site/guide/journey.md +0 -203
  34. package/docs/site/guide/migration-from-debug.md +0 -24
  35. package/docs/site/guide/spans.md +0 -139
  36. package/docs/site/guide/why.md +0 -55
  37. package/docs/site/guide/workers.md +0 -113
  38. package/docs/site/guide/zero-overhead.md +0 -87
  39. package/docs/site/index.md +0 -54
  40. package/tests/features.test.ts +0 -552
  41. package/tests/logger.test.ts +0 -944
  42. package/tests/tracing.test.ts +0 -618
  43. package/tests/universal.test.ts +0 -107
  44. package/tests/worker.test.ts +0 -590
  45. package/tsconfig.json +0 -20
  46. package/vitest.config.ts +0 -10
package/README.md CHANGED
@@ -1,25 +1,29 @@
1
- # loggily
1
+ # Loggily
2
2
 
3
3
  **Clarity without the clutter.**
4
4
 
5
+ One library. One namespace tree. One output pipeline. For logs (structured JSON or console), debug(), and tracing spans. Near-zero overhead from disabled log levels. Pure TypeScript. ~3KB. Zero dependencies.
6
+
5
7
  [![Tests](https://github.com/beorn/loggily/actions/workflows/test.yml/badge.svg)](https://github.com/beorn/loggily/actions/workflows/test.yml)
6
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/)
8
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.2+-blue.svg)](https://www.typescriptlang.org/)
7
9
  [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
8
10
 
9
- Debug logging, structured logs, and distributed tracing integrated into one **~3KB** library with a single API. Zero dependencies.
10
-
11
- Most projects wire together three separate tools that don't talk to each other: **debug** for conditional output, **pino/winston** for production logs, **OpenTelemetry** for tracing. loggily integrates all three into one unified system — same namespace tree, same output pipeline, same `?.` zero-overhead pattern. Every logger is a potential span: call `.span()` and it becomes one, with automatic timing, parent-child tracking, and trace IDs. Nothing to sync, nothing to configure separately.
12
-
13
- In development, you get colorized console output with timestamps, level colors, and clickable source lines — loggily uses native `console` methods so stack traces stay intact in DevTools. In production, the same code emits structured JSON. No config change needed.
14
-
15
- Read **[The Journey](docs/guide.md)** for the full story.
11
+ > Early release (0.x) -- API may evolve before 1.0.
16
12
 
17
13
  ## Install
18
14
 
19
15
  ```bash
20
- bun add loggily # or: npm install loggily
16
+ npm install loggily
21
17
  ```
22
18
 
19
+ | Requirement | Version |
20
+ | ------------- | ------------------------------------------------- |
21
+ | Node.js | 18+ |
22
+ | Bun | 1.0+ |
23
+ | TypeScript | 5.2+ (for `using`; `.end()` works on any version) |
24
+ | Module format | ESM-only |
25
+ | Browser | Supported via conditional export |
26
+
23
27
  ## Quick Start
24
28
 
25
29
  ```typescript
@@ -27,55 +31,96 @@ import { createLogger } from "loggily"
27
31
 
28
32
  const log = createLogger("myapp")
29
33
 
30
- // ?. skips the entire call — including argument evaluation — when the level is disabled (near-zero cost)
34
+ // ?. skips the entire call — including argument evaluation — when the level is disabled
31
35
  log.info?.("server started", { port: 3000 })
32
36
  log.debug?.("cache hit", { key: "user:42" })
33
37
  log.error?.(new Error("connection lost"))
38
+ ```
39
+
40
+ Output in development (colorized with timestamps and clickable source lines):
41
+
42
+ ```
43
+ 14:32:15 INFO myapp server started {port: 3000}
44
+ 14:32:15 DEBUG myapp cache hit {key: "user:42"}
45
+ 14:32:15 ERROR myapp connection lost
46
+ ```
47
+
48
+ Set `NODE_ENV=production` or `LOG_FORMAT=json` and the same code emits structured JSON:
49
+
50
+ ```json
51
+ { "time": "2024-01-15T14:32:15.123Z", "level": "info", "name": "myapp", "msg": "server started", "port": 3000 }
52
+ ```
34
53
 
35
- // Spans time operations automatically
54
+ ### Spans
55
+
56
+ Time operations with lightweight spans. Uses TC39 [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) (`using` requires TypeScript 5.2+ and runtime support). For other environments, call `.end()` manually:
57
+
58
+ ```typescript
59
+ // With `using` (TS 5.2+, Bun 1.0+, Node 22+)
36
60
  {
37
61
  using span = log.span("db:query", { table: "users" })
38
62
  const users = await db.query("SELECT * FROM users")
39
63
  span.spanData.count = users.length
40
64
  }
41
65
  // Output: SPAN myapp:db:query (45ms) {count: 100, table: "users"}
66
+
67
+ // Without `using` — works on any runtime
68
+ const span = log.span("db:query", { table: "users" })
69
+ try {
70
+ const users = await db.query("SELECT * FROM users")
71
+ span.spanData.count = users.length
72
+ } finally {
73
+ span.end()
74
+ }
42
75
  ```
43
76
 
44
- ## Why Another Logger?
77
+ ## Why Loggily?
78
+
79
+ One API for debug-style namespace logging, structured JSON output, and lightweight spans. Many projects end up with separate tools for these -- **debug** for conditional output, **pino/winston** for production logs, a tracing SDK for timings -- with separate configs, formats, and APIs. Loggily integrates all three into one namespace tree, one output pipeline, one `?.` pattern.
80
+
81
+ ### Near-zero cost for disabled logs
45
82
 
46
- Beyond the integration story above, most loggers also waste work when logging is disabled. Even with a noop function, arguments are still evaluated:
83
+ Most loggers waste work when logging is disabled. Even with a noop function, arguments are still evaluated:
47
84
 
48
85
  ```typescript
49
86
  // Traditional — args are ALWAYS evaluated, even when debug is off
50
87
  log.debug(`state: ${JSON.stringify(computeExpensiveState())}`)
51
88
  ```
52
89
 
53
- loggily uses optional chaining to skip the entire call — including argument evaluation:
90
+ Loggily uses optional chaining to skip the entire call — including argument evaluation:
54
91
 
55
92
  ```typescript
56
- // loggily — args are NOT evaluated when disabled
93
+ // Loggily — args are NOT evaluated when disabled
57
94
  log.debug?.(`state: ${JSON.stringify(computeExpensiveState())}`)
58
95
  ```
59
96
 
60
97
  For trivial arguments the difference is negligible. But for real-world logging — string interpolation, JSON serialization, state snapshots — optional chaining is typically **10x+ faster** because it skips the work entirely. The more expensive your arguments, the bigger the win.
61
98
 
99
+ > **Note**: The big performance advantage is specifically for disabled logging with expensive arguments, not universal logger throughput. Pino is optimized for high-throughput enabled JSON logging; Loggily's biggest advantage is skipping work when logs are disabled. See [benchmarks](https://beorn.codes/loggily/guide/benchmarks) for detailed numbers per scenario.
100
+
62
101
  ## Features
63
102
 
64
103
  - **Namespace hierarchy** — organize logs with `:` separators. `log.logger("db")` creates `myapp:db`. Children inherit parent context.
65
- - **Spans** — time any operation with `using span = log.span("name")`. Automatic duration, parent-child tracking, and trace IDs. _(Uses TC39 [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management); call `span.end()` manually if your runtime doesn't support `using` yet.)_
104
+ - **Lightweight spans and trace IDs** — time any operation with `using span = log.span("name")`. Automatic duration, parent-child tracking, and trace IDs. For full OpenTelemetry interoperability with exporters and propagation, use OpenTelemetry.
66
105
  - **Lazy messages** — `log.debug?.(() => expensiveString())` skips the function entirely when disabled.
67
106
  - **Child context** — `log.child({ requestId })` adds structured fields to every message in the chain.
68
107
  - **Dev & production** — colorized console with timestamps, level colors, and clickable source lines in development. Structured JSON in production. Switches automatically via `NODE_ENV` — same code, zero config.
69
108
  - **File writer** — `addWriter()` + `createFileWriter()` for buffered file output with auto-flush.
70
109
  - **Worker threads** — forward logs from workers to the main thread with full type safety (`loggily/worker`).
71
- - **Drop-in debug replacement** — reads `DEBUG=myapp:*` just like the debug package. Swap your imports in minutes.
110
+ - **debug-compatible namespace filtering** — reads `DEBUG=myapp:*` just like the debug package. Easy migration from debug — see the [migration guide](https://beorn.codes/loggily/guide/migration-from-debug).
111
+
112
+ ## When Not to Use Loggily
113
+
114
+ - **Max-throughput transport pipelines** — use [Pino](https://getpino.io/) for worker-thread transports, custom serializers, and log rotation.
115
+ - **Vendor/exporter interop** — use [OpenTelemetry](https://opentelemetry.io/) for distributed tracing with propagation, semantic conventions, and backend integrations.
116
+ - **Tiny dev-only namespace logs** — use [debug](https://github.com/debug-js/debug) if all you need is conditional dev output with zero ceremony.
72
117
 
73
118
  ## Documentation
74
119
 
75
- - **[The Journey](docs/guide.md)** — progressive guide from first log to full observability
120
+ - **[Get Started](https://beorn.codes/loggily/guide/journey)** — progressive guide from first log to full observability
76
121
  - **[Full docs site](https://beorn.codes/loggily/)** — guides, API reference, migration guides
77
- - [Comparison](docs/comparison.md) — vs Pino, Winston, Bunyan, debug
78
- - [Migration from debug](docs/migration-from-debug.md) — step-by-step migration guide
122
+ - [Comparison](https://beorn.codes/loggily/guide/comparison) — vs Pino, Winston, Bunyan, debug
123
+ - [Migration from debug](https://beorn.codes/loggily/guide/migration-from-debug) — step-by-step migration guide
79
124
 
80
125
  ## Environment Variables
81
126
 
@@ -101,7 +146,7 @@ For trivial arguments the difference is negligible. But for real-world logging
101
146
  | `setLogLevel()` / `setLogFormat()` / `enableSpans()` | Runtime configuration |
102
147
  | `createWorkerLogger()` / `createWorkerLogHandler()` | Worker thread support (`loggily/worker`) |
103
148
 
104
- See the [full API reference](docs/api-reference.md) for all functions and options.
149
+ See the [full API reference](https://beorn.codes/loggily/api/) for all functions and options.
105
150
 
106
151
  ## License
107
152
 
package/package.json CHANGED
@@ -1,27 +1,37 @@
1
1
  {
2
2
  "name": "loggily",
3
- "version": "0.3.0",
4
- "description": "Structured logging with spans. ~3KB, zero-overhead disabled logging via optional chaining.",
3
+ "version": "0.4.0",
4
+ "description": "TypeScript logger with debug-style namespaces, structured JSON output, and lightweight spans. Disabled logs skip argument evaluation via optional chaining.",
5
5
  "keywords": [
6
+ "browser",
7
+ "bun",
8
+ "debug",
9
+ "json-logger",
6
10
  "logger",
7
11
  "logging",
12
+ "namespace",
13
+ "node",
14
+ "observability",
8
15
  "optional-chaining",
9
16
  "spans",
10
- "structured",
17
+ "structured-logging",
11
18
  "tracing",
12
- "typescript",
13
- "zero-overhead"
19
+ "typescript"
14
20
  ],
15
- "homepage": "https://beorn.codes/decant/",
21
+ "homepage": "https://beorn.codes/loggily/",
16
22
  "bugs": {
17
- "url": "https://github.com/beorn/decant/issues"
23
+ "url": "https://github.com/beorn/loggily/issues"
18
24
  },
19
25
  "license": "MIT",
20
26
  "author": "Beorn",
21
27
  "repository": {
22
28
  "type": "git",
23
- "url": "https://github.com/beorn/decant.git"
29
+ "url": "https://github.com/beorn/loggily.git"
24
30
  },
31
+ "files": [
32
+ "src",
33
+ "dist"
34
+ ],
25
35
  "type": "module",
26
36
  "module": "src/index.ts",
27
37
  "types": "./dist/index.d.ts",
@@ -48,13 +58,16 @@
48
58
  "default": "./src/tracing.ts"
49
59
  }
50
60
  },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ },
51
64
  "scripts": {
52
65
  "build": "tsc",
53
66
  "typecheck": "tsc --noEmit",
54
67
  "test": "bunx --bun vitest run",
55
- "docs:dev": "vitepress dev docs/site",
56
- "docs:build": "vitepress build docs/site",
57
- "docs:preview": "vitepress preview docs/site"
68
+ "docs:dev": "vitepress dev docs",
69
+ "docs:build": "vitepress build docs",
70
+ "docs:preview": "vitepress preview docs"
58
71
  },
59
72
  "devDependencies": {
60
73
  "@types/node": "^25.3.3",
package/src/context.ts CHANGED
@@ -41,6 +41,13 @@ export interface SpanContext {
41
41
  let storage: AsyncLocalStorage<SpanContext> | null = null
42
42
  let contextEnabled = false
43
43
 
44
+ /**
45
+ * Map from spanId → the SpanContext that was active when the span was entered.
46
+ * Used to restore the exact previous context on exit, avoiding corruption
47
+ * from non-LIFO end() ordering.
48
+ */
49
+ const previousContexts = new Map<string, SpanContext | null>()
50
+
44
51
  // ============ API ============
45
52
 
46
53
  /**
@@ -99,30 +106,38 @@ export function getCurrentSpan(): SpanContext | null {
99
106
  * spans with `using` — since `using` doesn't wrap user code in a callback,
100
107
  * `enterWith()` is the right primitive.
101
108
  *
109
+ * Captures the full previous SpanContext snapshot so it can be restored
110
+ * exactly on exit, even with non-LIFO end() ordering.
111
+ *
102
112
  * @internal
103
113
  */
104
114
  export function enterSpanContext(spanId: string, traceId: string, parentId: string | null): void {
105
115
  if (!contextEnabled || !storage) return
116
+
117
+ // Capture the full previous context before overwriting
118
+ const previous = storage.getStore() ?? null
119
+ previousContexts.set(spanId, previous)
120
+
106
121
  storage.enterWith({ spanId, traceId, parentId })
107
122
  }
108
123
 
109
124
  /**
110
- * Restore the parent span context (called when a span ends).
111
- * Re-enters the parent's context, or clears the context if there is no parent.
125
+ * Restore the previous span context (called when a span ends).
126
+ * Restores the exact SpanContext snapshot captured at enter time,
127
+ * preventing corruption from non-LIFO end() ordering.
112
128
  *
113
129
  * @internal
114
130
  */
115
- export function exitSpanContext(parentId: string | null, parentTraceId: string | null): void {
131
+ export function exitSpanContext(spanId: string): void {
116
132
  if (!contextEnabled || !storage) return
117
- if (parentId && parentTraceId) {
118
- // Restore parent context — note: we don't have the parent's parentId
119
- // but that's fine since this context is only used for auto-tagging and
120
- // auto-parenting new child spans (which will read spanId and traceId).
121
- storage.enterWith({ spanId: parentId, traceId: parentTraceId, parentId: null })
133
+
134
+ const previous = previousContexts.get(spanId)
135
+ previousContexts.delete(spanId)
136
+
137
+ if (previous) {
138
+ storage.enterWith(previous)
122
139
  } else {
123
- // No parent — exit the context entirely
124
- // enterWith(undefined as any) is not ideal, but there's no "exitWith"
125
- // We use a sentinel to indicate "no active span"
140
+ // No previous context — exit entirely
126
141
  storage.enterWith(undefined as unknown as SpanContext)
127
142
  }
128
143
  }
package/src/core.ts CHANGED
@@ -179,26 +179,39 @@ if (traceEnv && traceEnv !== "1" && traceEnv !== "true") {
179
179
 
180
180
  // Debug namespace filter (DEBUG=myapp or DEBUG=myapp,-myapp:noisy or DEBUG=*)
181
181
  // Supports negative patterns with `-` prefix (like the `debug` npm package)
182
- const debugEnv = getEnv("DEBUG")
183
- let debugIncludes: Set<string> | null = null
184
- let debugExcludes: Set<string> | null = null
185
- if (debugEnv) {
186
- const parts = debugEnv.split(",").map((s) => s.trim())
187
- const includes: string[] = []
188
- const excludes: string[] = []
189
- for (const part of parts) {
182
+
183
+ /** Parse a comma-separated namespace filter into include/exclude sets */
184
+ function parseNamespaceFilter(input: string[]): {
185
+ includes: Set<string> | null
186
+ excludes: Set<string> | null
187
+ } {
188
+ const includeList: string[] = []
189
+ const excludeList: string[] = []
190
+ for (const part of input) {
190
191
  if (part.startsWith("-")) {
191
- excludes.push(part.slice(1))
192
+ excludeList.push(part.slice(1))
192
193
  } else {
193
- includes.push(part)
194
+ includeList.push(part)
194
195
  }
195
196
  }
196
- if (includes.length > 0) {
197
- debugIncludes = new Set(includes.some((p) => p === "*" || p === "1" || p === "true") ? ["*"] : includes)
197
+ return {
198
+ includes: includeList.length > 0 ? new Set(includeList) : null,
199
+ excludes: excludeList.length > 0 ? new Set(excludeList) : null,
198
200
  }
199
- if (excludes.length > 0) {
200
- debugExcludes = new Set(excludes)
201
+ }
202
+
203
+ const debugEnv = getEnv("DEBUG")
204
+ let debugIncludes: Set<string> | null = null
205
+ let debugExcludes: Set<string> | null = null
206
+ if (debugEnv) {
207
+ const parts = debugEnv.split(",").map((s) => s.trim())
208
+ const parsed = parseNamespaceFilter(parts)
209
+ debugIncludes = parsed.includes
210
+ // Normalize wildcard variants
211
+ if (debugIncludes && [...debugIncludes].some((p) => p === "*" || p === "1" || p === "true")) {
212
+ debugIncludes = new Set(["*"])
201
213
  }
214
+ debugExcludes = parsed.excludes
202
215
  // Auto-lower log level to at least debug when DEBUG is set
203
216
  if (LOG_LEVEL_PRIORITY[currentLogLevel] > LOG_LEVEL_PRIORITY.debug) {
204
217
  currentLogLevel = "debug"
@@ -261,17 +274,9 @@ export function setDebugFilter(namespaces: string[] | null): void {
261
274
  debugIncludes = null
262
275
  debugExcludes = null
263
276
  } else {
264
- const includes: string[] = []
265
- const excludes: string[] = []
266
- for (const ns of namespaces) {
267
- if (ns.startsWith("-")) {
268
- excludes.push(ns.slice(1))
269
- } else {
270
- includes.push(ns)
271
- }
272
- }
273
- debugIncludes = includes.length > 0 ? new Set(includes) : null
274
- debugExcludes = excludes.length > 0 ? new Set(excludes) : null
277
+ const parsed = parseNamespaceFilter(namespaces)
278
+ debugIncludes = parsed.includes
279
+ debugExcludes = parsed.excludes
275
280
  if (LOG_LEVEL_PRIORITY[currentLogLevel] > LOG_LEVEL_PRIORITY.debug) {
276
281
  currentLogLevel = "debug"
277
282
  }
@@ -334,8 +339,8 @@ let _getContextParent: (() => { spanId: string; traceId: string } | null) | null
334
339
  /** Hook to enter a span context (sets AsyncLocalStorage for the current async scope) */
335
340
  let _enterContext: ((spanId: string, traceId: string, parentId: string | null) => void) | null = null
336
341
 
337
- /** Hook to exit a span context (restores parent or clears) */
338
- let _exitContext: ((parentId: string | null, parentTraceId: string | null) => void) | null = null
342
+ /** Hook to exit a span context (restores previous context snapshot) */
343
+ let _exitContext: ((spanId: string) => void) | null = null
339
344
 
340
345
  /**
341
346
  * Register context propagation hooks (called by context.ts).
@@ -345,7 +350,7 @@ export function _setContextHooks(hooks: {
345
350
  getContextTags: () => Record<string, string>
346
351
  getContextParent: () => { spanId: string; traceId: string } | null
347
352
  enterContext: (spanId: string, traceId: string, parentId: string | null) => void
348
- exitContext: (parentId: string | null, parentTraceId: string | null) => void
353
+ exitContext: (spanId: string) => void
349
354
  }): void {
350
355
  _getContextTags = hooks.getContextTags
351
356
  _getContextParent = hooks.getContextParent
@@ -376,6 +381,24 @@ function shouldTraceNamespace(namespace: string): boolean {
376
381
  return matchesNamespaceSet(namespace, traceFilter)
377
382
  }
378
383
 
384
+ /**
385
+ * Safe JSON.stringify that handles bigint, circular refs, symbols, and Error objects.
386
+ * Prevents crashes from non-serializable values in log data.
387
+ */
388
+ function safeStringify(value: unknown): string {
389
+ const seen = new WeakSet()
390
+ return JSON.stringify(value, (_key, val) => {
391
+ if (typeof val === "bigint") return val.toString()
392
+ if (typeof val === "symbol") return val.toString()
393
+ if (val instanceof Error) return { message: val.message, stack: val.stack, name: val.name }
394
+ if (typeof val === "object" && val !== null) {
395
+ if (seen.has(val)) return "[Circular]"
396
+ seen.add(val)
397
+ }
398
+ return val
399
+ })
400
+ }
401
+
379
402
  function formatConsole(namespace: string, level: string, message: string, data?: Record<string, unknown>): string {
380
403
  const time = pc.dim(new Date().toISOString().split("T")[1]?.split(".")[0] || "")
381
404
 
@@ -405,7 +428,7 @@ function formatConsole(namespace: string, level: string, message: string, data?:
405
428
  let output = `${time} ${levelStr} ${ns} ${message}`
406
429
 
407
430
  if (data && Object.keys(data).length > 0) {
408
- output += ` ${pc.dim(JSON.stringify(data))}`
431
+ output += ` ${pc.dim(safeStringify(data))}`
409
432
  }
410
433
 
411
434
  return output
@@ -419,14 +442,7 @@ function formatJSON(namespace: string, level: string, message: string, data?: Re
419
442
  msg: message,
420
443
  ...data,
421
444
  }
422
- const seen = new WeakSet()
423
- return JSON.stringify(entry, (_key, value) => {
424
- if (typeof value === "object" && value !== null) {
425
- if (seen.has(value)) return "[Circular]"
426
- seen.add(value)
427
- }
428
- return value
429
- })
445
+ return safeStringify(entry)
430
446
  }
431
447
 
432
448
  function matchesNamespaceSet(namespace: string, set: Set<string>): boolean {
@@ -502,7 +518,7 @@ function writeLog(
502
518
  }
503
519
  }
504
520
 
505
- function writeSpan(namespace: string, duration: number, attrs: Record<string, unknown>): void {
521
+ export function writeSpan(namespace: string, duration: number, attrs: Record<string, unknown>): void {
506
522
  if (!shouldTraceNamespace(namespace)) return
507
523
  if (!shouldDebugNamespace(namespace)) return
508
524
 
@@ -515,6 +531,40 @@ function writeSpan(namespace: string, duration: number, attrs: Record<string, un
515
531
  if (!suppressConsole) writeStderr(formatted)
516
532
  }
517
533
 
534
+ // ============ Shared SpanData Proxy ============
535
+
536
+ interface SpanDataFields {
537
+ id: string
538
+ traceId: string
539
+ parentId: string | null
540
+ startTime: number
541
+ endTime: number | null
542
+ duration: number | null
543
+ }
544
+
545
+ /**
546
+ * Create a proxy that exposes span metadata as readonly and custom attributes as writable.
547
+ * Shared between core logger spans and worker logger spans.
548
+ */
549
+ export function createSpanDataProxy(getFields: () => SpanDataFields, attrs: Record<string, unknown>): SpanData {
550
+ const READONLY_KEYS = new Set(["id", "traceId", "parentId", "startTime", "endTime", "duration"])
551
+ return new Proxy(attrs, {
552
+ get(_target, prop) {
553
+ if (READONLY_KEYS.has(prop as string)) {
554
+ return getFields()[prop as keyof SpanDataFields]
555
+ }
556
+ return attrs[prop as string]
557
+ },
558
+ set(_target, prop, value) {
559
+ if (READONLY_KEYS.has(prop as string)) {
560
+ return false
561
+ }
562
+ attrs[prop as string] = value
563
+ return true
564
+ },
565
+ }) as SpanData
566
+ }
567
+
518
568
  // ============ Implementation ============
519
569
 
520
570
  interface MutableSpanData {
@@ -556,38 +606,17 @@ function createLoggerImpl(
556
606
 
557
607
  get spanData(): SpanData | null {
558
608
  if (!spanMeta) return null
559
- // Return proxy that allows attribute assignment
560
- return new Proxy(spanMeta.attrs, {
561
- get(_target, prop) {
562
- if (prop === "id") return spanMeta.id
563
- if (prop === "traceId") return spanMeta.traceId
564
- if (prop === "parentId") return spanMeta.parentId
565
- if (prop === "startTime") return spanMeta.startTime
566
- if (prop === "endTime") return spanMeta.endTime
567
- if (prop === "duration") {
568
- if (spanMeta.endTime !== null) {
569
- return spanMeta.endTime - spanMeta.startTime
570
- }
571
- return Date.now() - spanMeta.startTime
572
- }
573
- return spanMeta.attrs[prop as string]
574
- },
575
- set(_target, prop, value) {
576
- // Allow setting custom attributes
577
- if (
578
- prop !== "id" &&
579
- prop !== "traceId" &&
580
- prop !== "parentId" &&
581
- prop !== "startTime" &&
582
- prop !== "endTime" &&
583
- prop !== "duration"
584
- ) {
585
- spanMeta.attrs[prop as string] = value
586
- return true
587
- }
588
- return false
589
- },
590
- }) as SpanData
609
+ return createSpanDataProxy(
610
+ () => ({
611
+ id: spanMeta.id,
612
+ traceId: spanMeta.traceId,
613
+ parentId: spanMeta.parentId,
614
+ startTime: spanMeta.startTime,
615
+ endTime: spanMeta.endTime,
616
+ duration: spanMeta.endTime !== null ? spanMeta.endTime - spanMeta.startTime : Date.now() - spanMeta.startTime,
617
+ }),
618
+ spanMeta.attrs,
619
+ )
591
620
  },
592
621
 
593
622
  trace: (msg, data) => log("trace", msg, data),
@@ -655,8 +684,25 @@ function createLoggerImpl(
655
684
  newSpanData.endTime = Date.now()
656
685
  newSpanData.duration = newSpanData.endTime - newSpanData.startTime
657
686
 
658
- // Exit span context (restore parent or clear)
659
- _exitContext?.(resolvedParentId, resolvedParentId ? finalTraceId : null)
687
+ // Collect span data if collection is active
688
+ if (collectSpans) {
689
+ collectedSpans.push(
690
+ createSpanDataProxy(
691
+ () => ({
692
+ id: newSpanData.id,
693
+ traceId: newSpanData.traceId,
694
+ parentId: newSpanData.parentId,
695
+ startTime: newSpanData.startTime,
696
+ endTime: newSpanData.endTime,
697
+ duration: newSpanData.duration,
698
+ }),
699
+ { ...newSpanData.attrs },
700
+ ),
701
+ )
702
+ }
703
+
704
+ // Exit span context (restore previous context snapshot)
705
+ _exitContext?.(newSpanId)
660
706
 
661
707
  // Only emit span if sampled
662
708
  if (sampled) {
@@ -60,8 +60,8 @@ export function createFileWriter(filePath: string, options: FileWriterOptions =
60
60
  function flush(): void {
61
61
  if (buffer.length === 0 || fd === null) return
62
62
  const data = buffer
63
- buffer = ""
64
63
  writeSync(fd, data)
64
+ buffer = ""
65
65
  }
66
66
 
67
67
  // Set up periodic flush
@@ -93,12 +93,18 @@ export function createFileWriter(filePath: string, options: FileWriterOptions =
93
93
  clearInterval(timer)
94
94
  timer = null
95
95
  }
96
- flush()
97
- if (fd !== null) {
98
- closeSync(fd)
99
- fd = null
96
+ try {
97
+ flush()
98
+ } catch {
99
+ // Swallow flush errors during close — data loss is unavoidable
100
+ // at this point, but we must still release the fd and exit handler.
101
+ } finally {
102
+ if (fd !== null) {
103
+ closeSync(fd)
104
+ fd = null
105
+ }
106
+ process.removeListener("exit", exitHandler)
100
107
  }
101
- process.removeListener("exit", exitHandler)
102
108
  },
103
109
  }
104
110
  }
@@ -51,7 +51,15 @@ export {
51
51
  } from "./core.js"
52
52
 
53
53
  // Tracing utilities (runtime-agnostic, work in browser)
54
- export { setIdFormat, getIdFormat, type IdFormat, traceparent, setSampleRate, getSampleRate } from "./tracing.js"
54
+ export {
55
+ setIdFormat,
56
+ getIdFormat,
57
+ type IdFormat,
58
+ traceparent,
59
+ type TraceparentOptions,
60
+ setSampleRate,
61
+ getSampleRate,
62
+ } from "./tracing.js"
55
63
 
56
64
  // File writer types (exported for type compatibility, but the function throws)
57
65
  export type { FileWriterOptions, FileWriter } from "./file-writer.js"
package/src/index.ts CHANGED
@@ -7,4 +7,12 @@
7
7
 
8
8
  export * from "./core.js"
9
9
  export { createFileWriter, type FileWriter, type FileWriterOptions } from "./file-writer.js"
10
- export { setIdFormat, getIdFormat, type IdFormat, traceparent, setSampleRate, getSampleRate } from "./tracing.js"
10
+ export {
11
+ setIdFormat,
12
+ getIdFormat,
13
+ type IdFormat,
14
+ traceparent,
15
+ type TraceparentOptions,
16
+ setSampleRate,
17
+ getSampleRate,
18
+ } from "./tracing.js"
package/src/tracing.ts CHANGED
@@ -64,6 +64,12 @@ export function resetIdCounters(): void {
64
64
 
65
65
  // ============ W3C Traceparent ============
66
66
 
67
+ /** Options for traceparent header formatting */
68
+ export interface TraceparentOptions {
69
+ /** Whether this span is sampled. Defaults to true for backwards compatibility. */
70
+ sampled?: boolean
71
+ }
72
+
67
73
  /**
68
74
  * Format a W3C traceparent header from span data.
69
75
  *
@@ -71,11 +77,12 @@ export function resetIdCounters(): void {
71
77
  * - version: "00" (current W3C spec version)
72
78
  * - trace-id: 32 hex chars (128 bits)
73
79
  * - span-id: 16 hex chars (64 bits)
74
- * - trace-flags: "01" (sampled)
80
+ * - trace-flags: "01" (sampled) or "00" (not sampled)
75
81
  *
76
82
  * Works with both simple and W3C ID formats. Simple IDs are zero-padded to spec length.
77
83
  *
78
84
  * @param spanData - Span data with id and traceId
85
+ * @param options - Optional settings (sampled flag). Defaults to sampled=true.
79
86
  * @returns W3C traceparent header string
80
87
  *
81
88
  * @example
@@ -86,10 +93,11 @@ export function resetIdCounters(): void {
86
93
  * fetch(url, { headers: { traceparent: header } })
87
94
  * ```
88
95
  */
89
- export function traceparent(spanData: SpanData): string {
96
+ export function traceparent(spanData: SpanData, options?: TraceparentOptions): string {
90
97
  const traceId = padHex(spanData.traceId, 32)
91
98
  const spanId = padHex(spanData.id, 16)
92
- return `00-${traceId}-${spanId}-01`
99
+ const flags = (options?.sampled ?? true) ? "01" : "00"
100
+ return `00-${traceId}-${spanId}-${flags}`
93
101
  }
94
102
 
95
103
  /** Pad or hash an ID to the specified hex length */