loggily 0.0.1 → 0.3.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 (48) hide show
  1. package/.github/workflows/docs.yml +58 -0
  2. package/.github/workflows/release.yml +31 -0
  3. package/.github/workflows/test.yml +20 -0
  4. package/CHANGELOG.md +45 -0
  5. package/CLAUDE.md +299 -0
  6. package/CONTRIBUTING.md +58 -0
  7. package/LICENSE +21 -0
  8. package/README.md +102 -3
  9. package/benchmarks/overhead.ts +267 -0
  10. package/bun.lock +479 -0
  11. package/docs/api-reference.md +400 -0
  12. package/docs/benchmarks.md +106 -0
  13. package/docs/comparison.md +315 -0
  14. package/docs/conditional-logging-research.md +159 -0
  15. package/docs/guide.md +205 -0
  16. package/docs/migration-from-debug.md +310 -0
  17. package/docs/migration-from-pino.md +178 -0
  18. package/docs/migration-from-winston.md +179 -0
  19. package/docs/site/.vitepress/config.ts +67 -0
  20. package/docs/site/api/configuration.md +94 -0
  21. package/docs/site/api/index.md +61 -0
  22. package/docs/site/api/logger.md +99 -0
  23. package/docs/site/api/worker.md +120 -0
  24. package/docs/site/api/writers.md +69 -0
  25. package/docs/site/guide/getting-started.md +143 -0
  26. package/docs/site/guide/journey.md +203 -0
  27. package/docs/site/guide/migration-from-debug.md +24 -0
  28. package/docs/site/guide/spans.md +139 -0
  29. package/docs/site/guide/why.md +55 -0
  30. package/docs/site/guide/workers.md +113 -0
  31. package/docs/site/guide/zero-overhead.md +87 -0
  32. package/docs/site/index.md +54 -0
  33. package/package.json +56 -8
  34. package/src/colors.ts +27 -0
  35. package/src/context.ts +155 -0
  36. package/src/core.ts +804 -0
  37. package/src/file-writer.ts +104 -0
  38. package/src/index.browser.ts +64 -0
  39. package/src/index.ts +10 -1
  40. package/src/tracing.ts +142 -0
  41. package/src/worker.ts +687 -0
  42. package/tests/features.test.ts +552 -0
  43. package/tests/logger.test.ts +944 -0
  44. package/tests/tracing.test.ts +618 -0
  45. package/tests/universal.test.ts +107 -0
  46. package/tests/worker.test.ts +590 -0
  47. package/tsconfig.json +20 -0
  48. package/vitest.config.ts +10 -0
@@ -0,0 +1,315 @@
1
+ # Comparison with Other Loggers
2
+
3
+ How loggily compares to popular Node.js logging libraries.
4
+
5
+ ## Feature Comparison Table
6
+
7
+ | Feature | loggily | Pino | Winston | Bunyan | debug |
8
+ | ---------------------- | ----------- | ------- | --------- | --------- | --------- |
9
+ | **Log Levels** | Yes (5) | Yes (6) | Yes (7) | Yes (6) | No |
10
+ | **Structured Logging** | Yes | Yes | Yes | Yes | No |
11
+ | **JSON Output** | Yes | Yes | Yes | Yes | No |
12
+ | **Spans/Tracing** | Built-in | No | No | No | No |
13
+ | **Zero-cost Disabled** | Yes (`?.`) | No | No | No | No |
14
+ | **Child Loggers** | Yes | Yes | Yes | Yes | Manual |
15
+ | **Transports** | File writer | Yes | Yes | Yes | No |
16
+ | **Pretty Print** | Auto (dev) | Plugin | Plugin | Plugin | Yes |
17
+ | **Browser Support** | Partial | Yes | Yes | Yes | Yes |
18
+ | **Bundle Size** | ~3KB | ~17KB | ~200KB+ | ~30KB | ~2KB |
19
+ | **TypeScript** | Native | Yes | Types pkg | Types pkg | Types pkg |
20
+
21
+ ## vs Pino
22
+
23
+ [Pino](https://github.com/pinojs/pino) is the gold standard for high-performance Node.js logging.
24
+
25
+ ### Similarities
26
+
27
+ - Performance-focused design
28
+ - JSON output in production
29
+ - Child loggers with inherited context
30
+ - Minimal overhead
31
+
32
+ ### Differences
33
+
34
+ | Aspect | Pino | loggily |
35
+ | ------------------ | ------------------------------ | ---------------------------------- |
36
+ | Zero-cost disabled | Noop function (args evaluated) | Optional chaining (args skipped) |
37
+ | Spans | External (pino-opentelemetry) | Built-in |
38
+ | Transports | Built-in (worker threads) | File writer + custom via addWriter |
39
+ | Formatters | Plugin system | Console/JSON auto-switch |
40
+ | Serializers | Configurable | Fixed (Error auto-handled) |
41
+
42
+ ### When to Choose
43
+
44
+ **Choose Pino if:**
45
+
46
+ - You need transport plugins (file rotation, remote logging)
47
+ - You need custom serializers for complex objects
48
+ - You're building a large production system with multiple log destinations
49
+
50
+ **Choose loggily if:**
51
+
52
+ - You want zero-cost disabled logging via optional chaining
53
+ - You need built-in span timing
54
+ - You prefer simplicity over configuration
55
+ - Bundle size matters
56
+
57
+ ### Code Comparison
58
+
59
+ ```typescript
60
+ // Pino
61
+ import pino from "pino"
62
+ const log = pino({ level: "debug" })
63
+ const child = log.child({ requestId: "123" })
64
+ child.info({ user: "alice" }, "logged in")
65
+
66
+ // loggily
67
+ import { createLogger } from "loggily"
68
+ const log = createLogger("myapp")
69
+ const child = log.logger("request", { requestId: "123" })
70
+ child.info("logged in", { user: "alice" })
71
+ ```
72
+
73
+ ---
74
+
75
+ ## vs Winston
76
+
77
+ [Winston](https://github.com/winstonjs/winston) is the most popular Node.js logger with extensive transport support.
78
+
79
+ ### Similarities
80
+
81
+ - Multiple log levels
82
+ - Structured logging support
83
+ - Child loggers
84
+
85
+ ### Differences
86
+
87
+ | Aspect | Winston | loggily |
88
+ | ------------- | ---------------------- | ------------------- |
89
+ | Philosophy | Flexible, configurable | Simple, opinionated |
90
+ | Transports | 10+ built-in | stdout only |
91
+ | Configuration | Extensive | Minimal (env vars) |
92
+ | Performance | Moderate | High |
93
+ | Bundle Size | ~200KB+ | ~3KB |
94
+ | Spans | No | Built-in |
95
+
96
+ ### When to Choose
97
+
98
+ **Choose Winston if:**
99
+
100
+ - You need multiple transports (file, HTTP, database)
101
+ - You need custom formatters and filters
102
+ - You have complex logging requirements
103
+
104
+ **Choose loggily if:**
105
+
106
+ - You want minimal configuration
107
+ - Performance is critical
108
+ - You're logging to stdout (12-factor app)
109
+ - You need built-in timing spans
110
+
111
+ ### Code Comparison
112
+
113
+ ```typescript
114
+ // Winston
115
+ import winston from "winston"
116
+ const log = winston.createLogger({
117
+ level: "info",
118
+ format: winston.format.json(),
119
+ transports: [new winston.transports.Console()],
120
+ })
121
+ log.info("starting", { port: 3000 })
122
+
123
+ // loggily
124
+ import { createLogger } from "loggily"
125
+ const log = createLogger("myapp")
126
+ log.info("starting", { port: 3000 })
127
+ ```
128
+
129
+ ---
130
+
131
+ ## vs Bunyan
132
+
133
+ [Bunyan](https://github.com/trentm/node-bunyan) focuses on JSON logging with built-in CLI tools.
134
+
135
+ ### Similarities
136
+
137
+ - JSON-first output
138
+ - Child loggers
139
+ - Structured data
140
+
141
+ ### Differences
142
+
143
+ | Aspect | Bunyan | loggily |
144
+ | ------------- | ---------------------- | --------------------------- |
145
+ | Output Format | JSON only | Console (dev) / JSON (prod) |
146
+ | CLI Tools | bunyan CLI for viewing | None |
147
+ | Streams | Multiple streams | stdout only |
148
+ | Spans | No | Built-in |
149
+ | API | Verbose | Simple |
150
+
151
+ ### When to Choose
152
+
153
+ **Choose Bunyan if:**
154
+
155
+ - You want the bunyan CLI for log viewing
156
+ - You need multiple output streams
157
+ - JSON-only output is fine for development
158
+
159
+ **Choose loggily if:**
160
+
161
+ - You want readable console output in development
162
+ - You need built-in spans
163
+ - You prefer a simpler API
164
+
165
+ ### Code Comparison
166
+
167
+ ```typescript
168
+ // Bunyan
169
+ import bunyan from "bunyan"
170
+ const log = bunyan.createLogger({ name: "myapp" })
171
+ const child = log.child({ requestId: "123" })
172
+ child.info({ user: "alice" }, "logged in")
173
+
174
+ // loggily
175
+ import { createLogger } from "loggily"
176
+ const log = createLogger("myapp")
177
+ const child = log.logger("request", { requestId: "123" })
178
+ child.info("logged in", { user: "alice" })
179
+ ```
180
+
181
+ ---
182
+
183
+ ## vs debug
184
+
185
+ [debug](https://github.com/debug-js/debug) is a tiny debugging utility.
186
+
187
+ ### Similarities
188
+
189
+ - Minimal footprint
190
+ - Namespace-based organization
191
+ - Environment variable control
192
+
193
+ ### Differences
194
+
195
+ | Aspect | debug | loggily |
196
+ | ------------- | ----------------- | ----------------- |
197
+ | Log Levels | No (on/off) | Yes (5 levels) |
198
+ | Output Format | printf-style | Structured JSON |
199
+ | Spans | No | Built-in |
200
+ | Conditional | `.enabled` check | Optional chaining |
201
+ | Data | Inline in message | Separate object |
202
+
203
+ ### When to Choose
204
+
205
+ **Choose debug if:**
206
+
207
+ - You only need simple debugging output
208
+ - You don't need log levels
209
+ - You don't need structured data
210
+
211
+ **Choose loggily if:**
212
+
213
+ - You need log levels
214
+ - You need structured data
215
+ - You need timing spans
216
+ - You want zero-cost disabled logging
217
+
218
+ ### Code Comparison
219
+
220
+ ```typescript
221
+ // debug
222
+ import createDebug from "debug"
223
+ const debug = createDebug("myapp")
224
+ debug("user %s logged in", username)
225
+
226
+ // loggily
227
+ import { createLogger } from "loggily"
228
+ const log = createLogger("myapp")
229
+ log.info("user logged in", { username })
230
+ ```
231
+
232
+ See [migration-from-debug.md](./migration-from-debug.md) for a detailed migration guide.
233
+
234
+ ---
235
+
236
+ ## Unique Features of loggily
237
+
238
+ ### 1. Zero-cost Disabled Logging
239
+
240
+ Optional chaining skips argument evaluation entirely:
241
+
242
+ ```typescript
243
+ // Other loggers - args always evaluated
244
+ pino.debug(`expensive: ${computeState()}`) // computeState() runs even if disabled
245
+
246
+ // loggily - args skipped when disabled
247
+ log.debug?.(`expensive: ${computeState()}`) // computeState() NOT called if disabled
248
+ ```
249
+
250
+ **Benchmark (10M iterations):**
251
+
252
+ - Noop with expensive args: 17M ops/s (57.6ns)
253
+ - Optional chaining with expensive args: 408M ops/s (2.5ns) - **22x faster**
254
+
255
+ ### 2. Built-in Spans
256
+
257
+ No external tracing library needed:
258
+
259
+ ```typescript
260
+ {
261
+ using span = log.span("db:query", { table: "users" })
262
+ const users = await db.query("SELECT * FROM users")
263
+ span.spanData.count = users.length
264
+ }
265
+ // → SPAN myapp:db:query (45ms) {count: 100, table: "users"}
266
+ ```
267
+
268
+ Features:
269
+
270
+ - Automatic timing on block exit
271
+ - Parent-child relationships tracked
272
+ - Custom attributes via `spanData`
273
+ - Trace ID for request correlation
274
+
275
+ ### 3. Disposable Pattern
276
+
277
+ Uses JavaScript's `using` keyword for automatic cleanup:
278
+
279
+ ```typescript
280
+ {
281
+ using span = log.span("operation")
282
+ // ... work ...
283
+ } // Span automatically ends and emits timing
284
+
285
+ // No need for try/finally or .end() calls
286
+ ```
287
+
288
+ ### 4. Auto-format Switching
289
+
290
+ Console output in development, JSON in production:
291
+
292
+ ```bash
293
+ # Development (pretty console)
294
+ bun run app
295
+ # → 14:32:15 INFO myapp starting {port: 3000}
296
+
297
+ # Production (JSON)
298
+ NODE_ENV=production bun run app
299
+ # → {"time":"2024-01-15T14:32:15.123Z","level":"info","name":"myapp","msg":"starting","port":3000}
300
+ ```
301
+
302
+ ---
303
+
304
+ ## Summary
305
+
306
+ | Use Case | Recommended |
307
+ | --------------------------------------- | ---------------- |
308
+ | High-performance with optional chaining | loggily |
309
+ | Built-in span timing | loggily |
310
+ | Multiple transports | Pino or Winston |
311
+ | Extensive configuration | Winston |
312
+ | JSON CLI tools | Bunyan |
313
+ | Simple debugging only | debug |
314
+ | Minimal bundle size | debug or loggily |
315
+ | TypeScript-first | loggily or Pino |
@@ -0,0 +1,159 @@
1
+ # Conditional Logging Research
2
+
3
+ Background research for the optional chaining pattern in loggily.
4
+
5
+ ## The Problem
6
+
7
+ When logging is disabled, traditional loggers still evaluate arguments:
8
+
9
+ ```typescript
10
+ // Even when debug is disabled, expensiveState() is STILL called
11
+ log.debug("state: %o", computeExpensiveState())
12
+ ```
13
+
14
+ This is wasted work. In hot code paths (rendering loops, per-node operations), this overhead adds up.
15
+
16
+ ## Solution: Optional Chaining
17
+
18
+ JavaScript's optional chaining (`?.`) skips argument evaluation entirely when the method is undefined:
19
+
20
+ ```typescript
21
+ // When log.debug is undefined, the entire call is skipped
22
+ // computeExpensiveState() is NEVER called
23
+ log.debug?.("state: %o", computeExpensiveState())
24
+ ```
25
+
26
+ ## Benchmark Results (Bun 1.1.x, M1 Mac)
27
+
28
+ ```
29
+ DISABLED LOGGING (no arguments evaluated)
30
+ 1. noop function call 2168M ops/s 0.5ns/op
31
+ 2. optional chaining (?.) - undefined 1406M ops/s 0.7ns/op
32
+ 3. proxy returning undefined + ?. 545M ops/s 1.8ns/op
33
+ 4. proxy returning noop 352M ops/s 2.8ns/op
34
+
35
+ DISABLED LOGGING (with expensive argument)
36
+ 1. noop - args evaluated (wastes work) 17M ops/s 57.6ns/op
37
+ 2. optional chaining - args NOT evaluated 408M ops/s 2.5ns/op ← 22x faster
38
+ 3. proxy + ?. - args NOT evaluated 168M ops/s 5.9ns/op
39
+ 4. proxy + noop - args evaluated (wastes work) 15M ops/s 65.3ns/op
40
+ ```
41
+
42
+ **Key insight**: For cheap arguments, noop is only ~0.2ns faster. For expensive arguments, optional chaining is **22x faster** because it skips argument evaluation entirely.
43
+
44
+ ## External Research
45
+
46
+ ### Matteo Collina's Analysis (2025)
47
+
48
+ Article: [Noop Functions vs Optional Chaining: A Performance Deep Dive](https://adventures.nodeland.dev/archive/noop-functions-vs-optional-chaining-a-performance/)
49
+
50
+ Collina (Pino maintainer) benchmarked that noop functions outperform optional chaining in raw call overhead. However, his test used cheap arguments. The benchmark above shows that the **real benefit** of `?.` is skipping expensive argument evaluation, which Collina's test didn't measure.
51
+
52
+ Quote: "Discover why noop functions are faster than optional chaining in JavaScript"
53
+
54
+ **Our finding**: This is only true for cheap args. When args are expensive, `?.` wins decisively.
55
+
56
+ ### Lazy Logging in JavaScript (2023)
57
+
58
+ Article: [Lazy/conditional logging in JavaScript](https://tonisives.com/blog/2023/05/28/lazy-conditional-logging-in-javascript/)
59
+
60
+ Toni Sives describes passing functions to delay evaluation:
61
+
62
+ ```typescript
63
+ Logger.trace(() => `long task took ${longRunningTask()}`)
64
+ ```
65
+
66
+ This works but requires wrapping every call in an arrow function. Optional chaining is more ergonomic:
67
+
68
+ ```typescript
69
+ log.trace?.(`long task took ${longRunningTask()}`)
70
+ ```
71
+
72
+ ### TC39 Explicit Resource Management
73
+
74
+ The `using` keyword (Stage 3 as of June 2024) enables automatic span disposal:
75
+
76
+ ```typescript
77
+ {
78
+ using span = log.span("operation")
79
+ span.debug("working...")
80
+ } // Automatically calls span[Symbol.dispose]()
81
+ ```
82
+
83
+ Reference: [TC39 Proposal](https://tc39.es/proposal-explicit-resource-management/)
84
+
85
+ ### Proxy Performance (Historical)
86
+
87
+ Valeri Karpov's 2016 benchmarks showed Proxies were ~10x slower than direct property access. Modern V8 has improved significantly, but Proxy still adds overhead (~1-3ns per access). For logging, this is negligible compared to actual I/O.
88
+
89
+ Reference: [Thoughts on ES6 Proxies Performance](https://thecodebarbarian.com/thoughts-on-es6-proxies-performance)
90
+
91
+ ## Implementation Approach
92
+
93
+ ### Proxy Wrapper
94
+
95
+ ```typescript
96
+ import { createLogger, getLogLevel } from "loggily"
97
+
98
+ const baseLog = createLogger("myapp")
99
+
100
+ const LEVEL_PRIORITY = {
101
+ trace: 0,
102
+ debug: 1,
103
+ info: 2,
104
+ warn: 3,
105
+ error: 4,
106
+ silent: 5,
107
+ }
108
+
109
+ export const log = new Proxy(baseLog, {
110
+ get(target, prop: string) {
111
+ // For level methods, return undefined if disabled
112
+ if (prop in LEVEL_PRIORITY) {
113
+ const current = LEVEL_PRIORITY[getLogLevel() as keyof typeof LEVEL_PRIORITY]
114
+ if (LEVEL_PRIORITY[prop as keyof typeof LEVEL_PRIORITY] < current) {
115
+ return undefined
116
+ }
117
+ }
118
+ return (target as any)[prop]
119
+ },
120
+ })
121
+ ```
122
+
123
+ ### TypeScript Types
124
+
125
+ ```typescript
126
+ type ConditionalLogger = {
127
+ trace?: (msg: string, data?: object) => void
128
+ debug?: (msg: string, data?: object) => void
129
+ info?: (msg: string, data?: object) => void
130
+ warn?: (msg: string, data?: object) => void
131
+ error: (msg: string, data?: object) => void // Always available
132
+ logger: (ns?: string, props?: object) => ConditionalLogger
133
+ span?: (ns?: string, props?: object) => SpanLogger | undefined
134
+ }
135
+ ```
136
+
137
+ TypeScript enforces the `?.` pattern at compile time - you can't call `log.debug()` without `?.` because the method may be undefined.
138
+
139
+ ## Design Decision: Always Use `?.`
140
+
141
+ Given the benchmark results:
142
+
143
+ - Overhead of `?.` vs noop for cheap args: ~0.2ns (negligible)
144
+ - Benefit of `?.` for expensive args: ~55ns saved (22x faster)
145
+
146
+ **Conclusion**: Always use `log.debug?.()`. The ergonomic cost is minimal (just add `?.`), and the performance benefit is significant when arguments involve any computation.
147
+
148
+ ## Alternative: Lazy Evaluation
149
+
150
+ For very expensive argument preparation, use a function:
151
+
152
+ ```typescript
153
+ log.debug?.(() => {
154
+ const state = gatherComplexState()
155
+ return ["state: %o", state]
156
+ })
157
+ ```
158
+
159
+ The logger calls the function only if debug is enabled. This pattern is useful when you need to prepare multiple pieces of data that depend on each other.
package/docs/guide.md ADDED
@@ -0,0 +1,205 @@
1
+ # The Guide
2
+
3
+ > Clarity without the clutter. Ergonomic unified logs, spans, metrics, and debugs for modern TypeScript.
4
+
5
+ Your first app uses `console.log`. That's enough for a script, a prototype, a small server. Then your app grows. You need structured logs for production, the `debug` package for conditional verbose output, a tracing library for timings, maybe OpenTelemetry for distributed traces — and suddenly you're juggling three tools with three APIs, three configuration schemes, and three output formats.
6
+
7
+ loggily is one library where structured logging, debug-style conditional output, timed spans, and metrics all share the same namespace tree, the same output pipeline, and the same zero-overhead pattern. You adopt each capability when you need it. Nothing is wasted, nothing conflicts, nothing clutters your code.
8
+
9
+ ## Level 1: Just Log
10
+
11
+ You need structured logging with levels. One import, one function.
12
+
13
+ ```typescript
14
+ import { createLogger } from "loggily"
15
+
16
+ const log = createLogger("myapp")
17
+
18
+ log.info?.("server started", { port: 3000 })
19
+ log.warn?.("disk space low", { free: "2GB" })
20
+ log.error?.(new Error("connection failed"))
21
+ ```
22
+
23
+ Notice the `?.` — if a log level is disabled, the entire call is skipped, including argument evaluation. In benchmarks, this makes disabled log calls ~22x faster than a traditional logger that still evaluates its arguments. You get zero-cost logging for disabled levels, not just low-cost.
24
+
25
+ Colorized in your terminal, with source locations:
26
+
27
+ ```
28
+ 14:32:15 INFO myapp server started {port: 3000}
29
+ 14:32:15 WARN myapp disk space low {free: "2GB"}
30
+ 14:32:15 ERROR myapp connection failed
31
+ Error: connection failed
32
+ at server.ts:42
33
+ ```
34
+
35
+ Set `LOG_FORMAT=json` or `NODE_ENV=production` and the same calls produce structured JSON — same data, machine-parseable, ready for Datadog or Elastic or whatever your ops team uses:
36
+
37
+ ```json
38
+ { "time": "2024-01-15T14:32:15.123Z", "level": "info", "name": "myapp", "msg": "server started", "port": 3000 }
39
+ ```
40
+
41
+ You never choose between human-readable and machine-parseable. You get both from the same call.
42
+
43
+ **The wall**: Your app has 20 modules. You need verbose output from the database layer but not from the HTTP layer. `LOG_LEVEL=debug` turns on everything.
44
+
45
+ ## Level 2: Namespaces
46
+
47
+ Loggers form a tree. Child loggers inherit their parent's namespace and props:
48
+
49
+ ```typescript
50
+ const log = createLogger("myapp")
51
+ const db = log.logger("db") // myapp:db
52
+ const http = log.logger("http") // myapp:http
53
+ const query = db.logger("query") // myapp:db:query
54
+
55
+ db.debug?.("connecting") // myapp:db
56
+ query.debug?.("SELECT * FROM...") // myapp:db:query
57
+ ```
58
+
59
+ Now you can target output. `DEBUG` auto-lowers the log level to `debug` and restricts all output to matching namespaces:
60
+
61
+ ```bash
62
+ DEBUG=myapp:db bun run app # Only myapp:db namespace (all levels)
63
+ DEBUG='myapp:*,-myapp:http' bun run app # Everything except HTTP
64
+ LOG_LEVEL=debug bun run app # Debug level globally, all namespaces
65
+ ```
66
+
67
+ `DEBUG` is a namespace visibility filter inspired by the `debug` package — same patterns, same muscle memory — but as part of a full logging system with levels, structured data, and JSON output. Use `LOG_LEVEL` when you want to change the verbosity floor without restricting namespaces.
68
+
69
+ **The wall**: A request takes 3 seconds. You know it's slow, but you don't know which part.
70
+
71
+ ## Level 3: Spans
72
+
73
+ A span is a logger with a timer. It measures how long a block takes, and every log inside it inherits its context:
74
+
75
+ ```typescript
76
+ {
77
+ using span = log.span("import", { file: "data.csv" })
78
+ span.info?.("parsing rows")
79
+ span.spanData.count = 42
80
+ }
81
+ // -> SPAN myapp:import (1234ms) {count: 42, file: "data.csv"}
82
+ ```
83
+
84
+ The `using` keyword (TC39 Explicit Resource Management) automatically calls `span[Symbol.dispose]()` at block exit. The span measures its duration and reports it along with any attributes you set. No try/finally, no manual timing, no separate tracing SDK.
85
+
86
+ Spans nest. Each span gets a unique ID and shares its parent's trace ID, so you can correlate events across a request:
87
+
88
+ ```typescript
89
+ {
90
+ using req = log.span("request", { path: "/api/users" })
91
+ {
92
+ using db = req.span("db-query")
93
+ // db.spanData.traceId === req.spanData.traceId
94
+ // db.spanData.parentId === req.spanData.id
95
+ }
96
+ }
97
+ ```
98
+
99
+ Control span output independently from logs:
100
+
101
+ ```bash
102
+ TRACE=1 bun run app # All spans
103
+ TRACE=myapp:db bun run app # Only database spans
104
+ TRACE=myapp:db,myapp:cache bun run app # Database + cache spans
105
+ ```
106
+
107
+ **The wall**: Now you need logs sent elsewhere — a file, Datadog, your tracing backend — not just the console.
108
+
109
+ ## Level 4: Writers
110
+
111
+ The writer system is a simple function interface. Write once, send anywhere:
112
+
113
+ ```typescript
114
+ import { addWriter, createFileWriter } from "loggily"
115
+
116
+ // File writer with buffered auto-flush
117
+ const file = createFileWriter("/var/log/app.log")
118
+ addWriter((formatted, level) => file.write(formatted))
119
+
120
+ // Send to an HTTP endpoint
121
+ addWriter((formatted, level) => {
122
+ if (level === "error") fetch("/api/alerts", { method: "POST", body: formatted })
123
+ })
124
+
125
+ // Send spans to your tracing backend
126
+ addWriter((formatted, level) => {
127
+ if (level === "span") sendToJaeger(JSON.parse(formatted))
128
+ })
129
+ ```
130
+
131
+ You can attach multiple writers — each one receives every log and span. The logger doesn't care where the output goes; it just produces structured data. You decide where to send it.
132
+
133
+ Output modes let you control the default output:
134
+
135
+ ```typescript
136
+ import { setOutputMode } from "loggily"
137
+ setOutputMode("writers-only") // Only writers, no console
138
+ setOutputMode("stderr") // Bypass Ink/React console capture
139
+ setOutputMode("console") // Default: console.log/warn/error
140
+ ```
141
+
142
+ **The wall**: You spawn worker threads for heavy processing, but their logs vanish from the main output.
143
+
144
+ ## Level 5: Workers
145
+
146
+ Worker threads get their own loggers that forward to the main thread:
147
+
148
+ ```typescript
149
+ // worker.ts
150
+ import { createWorkerLogger } from "loggily/worker"
151
+
152
+ const log = createWorkerLogger(postMessage, "myapp:worker")
153
+ log.info?.("processing chunk", { size: 1000 })
154
+
155
+ {
156
+ using span = log.span("process")
157
+ // ...
158
+ }
159
+ ```
160
+
161
+ ```typescript
162
+ // main.ts
163
+ import { createWorkerLogHandler } from "loggily/worker"
164
+
165
+ const handler = createWorkerLogHandler()
166
+ worker.on("message", (msg) => handler(msg))
167
+ ```
168
+
169
+ Logs and spans from workers appear in the same output stream with the same formatting. No interleaving, no lost messages.
170
+
171
+ **The wall**: You need child loggers that carry request context through async call chains without passing the logger everywhere.
172
+
173
+ ## Level 6: Context
174
+
175
+ Child loggers carry structured context through async call chains. Create one at the request boundary, and every downstream log inherits its fields:
176
+
177
+ ```typescript
178
+ const reqLog = log.child({ requestId: "abc-123", userId: 42 })
179
+
180
+ reqLog.info?.("handling request")
181
+ // -> 14:32:15 INFO myapp handling request {requestId: "abc-123", userId: 42}
182
+
183
+ // Pass reqLog to downstream functions -- context propagates
184
+ await handleAuth(reqLog)
185
+ await handleQuery(reqLog)
186
+ ```
187
+
188
+ Every log from `reqLog` and its descendants carries `requestId` and `userId` without manual field-passing. In JSON mode, these become top-level fields — perfect for filtering in your log aggregator.
189
+
190
+ ## What You Have
191
+
192
+ Normally, you'd pull in one library for logs, another for debug prints, a tracing SDK for spans — and struggle to tie them together. With loggily, these aren't separate concerns. They're modes of the same tool.
193
+
194
+ At this point you've replaced that patchwork with a single library:
195
+
196
+ - **Structured logging** with levels, namespaces, colorized dev output, JSON production output, and source locations
197
+ - **Debug output** with `DEBUG=namespace:*` filtering — the `debug` package's power, integrated
198
+ - **Span timing** with `using` keyword, nested traces, and independent `TRACE=` control
199
+ - **Flexible output** via writers — file, HTTP, tracing backends, anything
200
+ - **Worker thread support** with automatic forwarding
201
+ - **Context propagation** via child loggers
202
+
203
+ All sharing one namespace tree. All respecting the same log levels. All using the same `?.` pattern — disabled calls are skipped entirely, including argument evaluation. There when you need it, invisible when you don't.
204
+
205
+ ~3KB. Zero dependencies. Modern TypeScript.