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
package/src/core.ts ADDED
@@ -0,0 +1,804 @@
1
+ /**
2
+ * loggily - Structured logging with spans
3
+ *
4
+ * Logger-first architecture: Span = Logger + Duration
5
+ *
6
+ * @example
7
+ * const log = createLogger('myapp')
8
+ *
9
+ * // Simple logging
10
+ * log.info('starting')
11
+ *
12
+ * // Lazy messages (function not called when level is disabled)
13
+ * log.debug?.(() => `expensive: ${computeState()}`)
14
+ *
15
+ * // Child loggers with context fields
16
+ * const reqLog = log.child({ requestId: 'abc' })
17
+ * reqLog.info('handling request') // includes requestId in every message
18
+ *
19
+ * // With timing (span)
20
+ * {
21
+ * using task = log.span('import', { file: 'data.csv' })
22
+ * task.info('importing')
23
+ * task.spanData.count = 42 // Set span attributes
24
+ * // Auto-disposal on block exit → SPAN myapp:import (15ms)
25
+ * }
26
+ */
27
+
28
+ import { colors as pc } from "./colors.js"
29
+
30
+ // ============ Runtime Detection ============
31
+
32
+ /** Cached process reference — undefined in browser/edge runtimes */
33
+ const _process = typeof process !== "undefined" ? process : undefined
34
+
35
+ /** Read an environment variable, returning undefined in non-Node runtimes */
36
+ function getEnv(key: string): string | undefined {
37
+ return _process?.env?.[key]
38
+ }
39
+
40
+ /** Write to stderr with console.error fallback for non-Node runtimes */
41
+ function writeStderr(text: string): void {
42
+ if (_process?.stderr?.write) {
43
+ _process.stderr.write(text + "\n")
44
+ } else {
45
+ console.error(text)
46
+ }
47
+ }
48
+
49
+ // ============ Types ============
50
+
51
+ /** Log levels that produce output */
52
+ export type OutputLogLevel = "trace" | "debug" | "info" | "warn" | "error"
53
+
54
+ /** All log levels including silent (for filtering) */
55
+ export type LogLevel = OutputLogLevel | "silent"
56
+
57
+ /** Message can be a string or a lazy function that returns a string */
58
+ export type LazyMessage = string | (() => string)
59
+
60
+ /** Span data accessible via logger.spanData */
61
+ export interface SpanData {
62
+ readonly id: string
63
+ readonly traceId: string
64
+ readonly parentId: string | null
65
+ readonly startTime: number
66
+ readonly endTime: number | null
67
+ readonly duration: number | null
68
+ /** Custom attributes - set via direct property assignment */
69
+ [key: string]: unknown
70
+ }
71
+
72
+ /** Logger interface */
73
+ export interface Logger {
74
+ /** Logger namespace (e.g., 'myapp:import') */
75
+ readonly name: string
76
+ /** Props inherited from parent + own props */
77
+ readonly props: Readonly<Record<string, unknown>>
78
+ /** Span data (non-null for span loggers, null for regular loggers) */
79
+ readonly spanData: SpanData | null
80
+
81
+ // Logging methods (accept string or lazy () => string)
82
+ trace(message: LazyMessage, data?: Record<string, unknown>): void
83
+ debug(message: LazyMessage, data?: Record<string, unknown>): void
84
+ info(message: LazyMessage, data?: Record<string, unknown>): void
85
+ warn(message: LazyMessage, data?: Record<string, unknown>): void
86
+ error(message: LazyMessage, data?: Record<string, unknown>): void
87
+ /** Error overload - extracts message, stack, code from Error */
88
+ error(error: Error, data?: Record<string, unknown>): void
89
+
90
+ // Create children
91
+ /** Create child logger (extends namespace, inherits props) */
92
+ logger(namespace?: string, props?: Record<string, unknown>): Logger
93
+ /** Create child span (extends namespace, inherits props, adds timing) */
94
+ span(namespace?: string, props?: Record<string, unknown>): SpanLogger
95
+
96
+ /** Create child logger with context fields merged into every message */
97
+ child(context: Record<string, unknown>): Logger
98
+ /** @deprecated Use .logger() instead for namespace-based children */
99
+ child(context: string): Logger
100
+
101
+ /** End span manually (alternative to using keyword) */
102
+ end(): void
103
+ }
104
+
105
+ /** Span logger - Logger with active span (spanData is non-null, implements Disposable) */
106
+ export interface SpanLogger extends Logger, Disposable {
107
+ readonly spanData: SpanData & {
108
+ /** Mutable attributes - set directly */
109
+ [key: string]: unknown
110
+ }
111
+ }
112
+
113
+ // ============ Writers ============
114
+
115
+ type LogWriter = (formatted: string, level: string) => void
116
+ const writers: LogWriter[] = []
117
+
118
+ /** Add a writer that receives all formatted log output. Returns unsubscribe. */
119
+ export function addWriter(writer: LogWriter): () => void {
120
+ writers.push(writer)
121
+ return () => {
122
+ const idx = writers.indexOf(writer)
123
+ if (idx !== -1) writers.splice(idx, 1)
124
+ }
125
+ }
126
+
127
+ let suppressConsole = false
128
+
129
+ /** Suppress console output from the logger (writers still receive output). */
130
+ export function setSuppressConsole(value: boolean): void {
131
+ suppressConsole = value
132
+ }
133
+
134
+ /** Output mode for writeLog */
135
+ export type OutputMode = "console" | "stderr" | "writers-only"
136
+ let outputMode: OutputMode = "console"
137
+
138
+ /** Set output mode for log messages (not spans — spans always use stderr). */
139
+ export function setOutputMode(mode: OutputMode): void {
140
+ outputMode = mode
141
+ }
142
+
143
+ /** Get current output mode */
144
+ export function getOutputMode(): OutputMode {
145
+ return outputMode
146
+ }
147
+
148
+ // ============ Configuration ============
149
+
150
+ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
151
+ trace: 0,
152
+ debug: 1,
153
+ info: 2,
154
+ warn: 3,
155
+ error: 4,
156
+ silent: 5,
157
+ }
158
+
159
+ // Initialize from environment
160
+ const envLogLevel = getEnv("LOG_LEVEL")?.toLowerCase()
161
+ let currentLogLevel: LogLevel =
162
+ envLogLevel === "trace" ||
163
+ envLogLevel === "debug" ||
164
+ envLogLevel === "info" ||
165
+ envLogLevel === "warn" ||
166
+ envLogLevel === "error" ||
167
+ envLogLevel === "silent"
168
+ ? envLogLevel
169
+ : "info"
170
+
171
+ // Span output control (TRACE=1 or TRACE=myapp,other)
172
+ const traceEnv = getEnv("TRACE")
173
+ let spansEnabled = traceEnv === "1" || traceEnv === "true"
174
+ let traceFilter: Set<string> | null = null
175
+ if (traceEnv && traceEnv !== "1" && traceEnv !== "true") {
176
+ traceFilter = new Set(traceEnv.split(",").map((s) => s.trim()))
177
+ spansEnabled = true
178
+ }
179
+
180
+ // Debug namespace filter (DEBUG=myapp or DEBUG=myapp,-myapp:noisy or DEBUG=*)
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) {
190
+ if (part.startsWith("-")) {
191
+ excludes.push(part.slice(1))
192
+ } else {
193
+ includes.push(part)
194
+ }
195
+ }
196
+ if (includes.length > 0) {
197
+ debugIncludes = new Set(includes.some((p) => p === "*" || p === "1" || p === "true") ? ["*"] : includes)
198
+ }
199
+ if (excludes.length > 0) {
200
+ debugExcludes = new Set(excludes)
201
+ }
202
+ // Auto-lower log level to at least debug when DEBUG is set
203
+ if (LOG_LEVEL_PRIORITY[currentLogLevel] > LOG_LEVEL_PRIORITY.debug) {
204
+ currentLogLevel = "debug"
205
+ }
206
+ }
207
+
208
+ /** Set minimum log level */
209
+ export function setLogLevel(level: LogLevel): void {
210
+ currentLogLevel = level
211
+ }
212
+
213
+ /** Get current log level */
214
+ export function getLogLevel(): LogLevel {
215
+ return currentLogLevel
216
+ }
217
+
218
+ /** Enable span output */
219
+ export function enableSpans(): void {
220
+ spansEnabled = true
221
+ }
222
+
223
+ /** Disable span output */
224
+ export function disableSpans(): void {
225
+ spansEnabled = false
226
+ }
227
+
228
+ /** Check if spans are enabled */
229
+ export function spansAreEnabled(): boolean {
230
+ return spansEnabled
231
+ }
232
+
233
+ /**
234
+ * Set trace filter for namespace-based span output control.
235
+ * Only spans matching these namespace prefixes will be output.
236
+ * @param namespaces - Array of namespace prefixes, or null to disable filtering
237
+ */
238
+ export function setTraceFilter(namespaces: string[] | null): void {
239
+ if (namespaces === null || namespaces.length === 0) {
240
+ traceFilter = null
241
+ } else {
242
+ traceFilter = new Set(namespaces)
243
+ spansEnabled = true
244
+ }
245
+ }
246
+
247
+ /** Get current trace filter (null means no filtering) */
248
+ export function getTraceFilter(): string[] | null {
249
+ return traceFilter ? [...traceFilter] : null
250
+ }
251
+
252
+ /**
253
+ * Set debug namespace filter (like the `debug` npm package).
254
+ * When set, only loggers matching these namespace prefixes produce output.
255
+ * Supports negative patterns with `-` prefix (e.g., ["-km:noisy"]).
256
+ * Also ensures log level is at least `debug`.
257
+ * @param namespaces - Array of namespace prefixes (prefix with `-` to exclude), or null to disable
258
+ */
259
+ export function setDebugFilter(namespaces: string[] | null): void {
260
+ if (namespaces === null || namespaces.length === 0) {
261
+ debugIncludes = null
262
+ debugExcludes = null
263
+ } 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
275
+ if (LOG_LEVEL_PRIORITY[currentLogLevel] > LOG_LEVEL_PRIORITY.debug) {
276
+ currentLogLevel = "debug"
277
+ }
278
+ }
279
+ }
280
+
281
+ /** Get current debug namespace filter (null means no filtering) */
282
+ export function getDebugFilter(): string[] | null {
283
+ if (!debugIncludes && !debugExcludes) return null
284
+ const result: string[] = []
285
+ if (debugIncludes) result.push(...debugIncludes)
286
+ if (debugExcludes) result.push(...[...debugExcludes].map((e) => `-${e}`))
287
+ return result
288
+ }
289
+
290
+ // ============ Log Format ============
291
+
292
+ /** Output format: human-readable console or structured JSON */
293
+ export type LogFormat = "console" | "json"
294
+
295
+ // Initialize from LOG_FORMAT env var, falling back to auto-detect
296
+ const envLogFormat = getEnv("LOG_FORMAT")?.toLowerCase()
297
+ let currentLogFormat: LogFormat = envLogFormat === "json" ? "json" : envLogFormat === "console" ? "console" : "console"
298
+
299
+ /** Set log output format */
300
+ export function setLogFormat(format: LogFormat): void {
301
+ currentLogFormat = format
302
+ }
303
+
304
+ /** Get current log output format */
305
+ export function getLogFormat(): LogFormat {
306
+ return currentLogFormat
307
+ }
308
+
309
+ /** Determine whether to use JSON formatting for the current call */
310
+ function useJsonFormat(): boolean {
311
+ return currentLogFormat === "json" || getEnv("NODE_ENV") === "production" || getEnv("TRACE_FORMAT") === "json"
312
+ }
313
+
314
+ // ============ ID Generation (delegated to tracing.ts) ============
315
+
316
+ import { generateSpanId, generateTraceId, resetIdCounters, shouldSample } from "./tracing.js"
317
+
318
+ // Reset for testing
319
+ export function resetIds(): void {
320
+ resetIdCounters()
321
+ }
322
+
323
+ // ============ Context Propagation Hooks ============
324
+
325
+ // These are set by context.ts when enableContextPropagation() is called.
326
+ // Kept as nullable callbacks to avoid importing AsyncLocalStorage in browser.
327
+
328
+ /** Hook to get current span context tags (trace_id, span_id) for auto-tagging logs */
329
+ let _getContextTags: (() => Record<string, string>) | null = null
330
+
331
+ /** Hook to get parent span info from async context */
332
+ let _getContextParent: (() => { spanId: string; traceId: string } | null) | null = null
333
+
334
+ /** Hook to enter a span context (sets AsyncLocalStorage for the current async scope) */
335
+ let _enterContext: ((spanId: string, traceId: string, parentId: string | null) => void) | null = null
336
+
337
+ /** Hook to exit a span context (restores parent or clears) */
338
+ let _exitContext: ((parentId: string | null, parentTraceId: string | null) => void) | null = null
339
+
340
+ /**
341
+ * Register context propagation hooks (called by context.ts).
342
+ * @internal
343
+ */
344
+ export function _setContextHooks(hooks: {
345
+ getContextTags: () => Record<string, string>
346
+ getContextParent: () => { spanId: string; traceId: string } | null
347
+ enterContext: (spanId: string, traceId: string, parentId: string | null) => void
348
+ exitContext: (parentId: string | null, parentTraceId: string | null) => void
349
+ }): void {
350
+ _getContextTags = hooks.getContextTags
351
+ _getContextParent = hooks.getContextParent
352
+ _enterContext = hooks.enterContext
353
+ _exitContext = hooks.exitContext
354
+ }
355
+
356
+ /**
357
+ * Clear context propagation hooks (called by disableContextPropagation).
358
+ * @internal
359
+ */
360
+ export function _clearContextHooks(): void {
361
+ _getContextTags = null
362
+ _getContextParent = null
363
+ _enterContext = null
364
+ _exitContext = null
365
+ }
366
+
367
+ // ============ Formatting ============
368
+
369
+ function shouldLog(level: LogLevel): boolean {
370
+ return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[currentLogLevel]
371
+ }
372
+
373
+ function shouldTraceNamespace(namespace: string): boolean {
374
+ if (!spansEnabled) return false
375
+ if (!traceFilter) return true
376
+ return matchesNamespaceSet(namespace, traceFilter)
377
+ }
378
+
379
+ function formatConsole(namespace: string, level: string, message: string, data?: Record<string, unknown>): string {
380
+ const time = pc.dim(new Date().toISOString().split("T")[1]?.split(".")[0] || "")
381
+
382
+ let levelStr = ""
383
+ switch (level) {
384
+ case "trace":
385
+ levelStr = pc.dim("TRACE")
386
+ break
387
+ case "debug":
388
+ levelStr = pc.dim("DEBUG")
389
+ break
390
+ case "info":
391
+ levelStr = pc.blue("INFO")
392
+ break
393
+ case "warn":
394
+ levelStr = pc.yellow("WARN")
395
+ break
396
+ case "error":
397
+ levelStr = pc.red("ERROR")
398
+ break
399
+ case "span":
400
+ levelStr = pc.magenta("SPAN")
401
+ break
402
+ }
403
+
404
+ const ns = pc.cyan(namespace)
405
+ let output = `${time} ${levelStr} ${ns} ${message}`
406
+
407
+ if (data && Object.keys(data).length > 0) {
408
+ output += ` ${pc.dim(JSON.stringify(data))}`
409
+ }
410
+
411
+ return output
412
+ }
413
+
414
+ function formatJSON(namespace: string, level: string, message: string, data?: Record<string, unknown>): string {
415
+ const entry = {
416
+ time: new Date().toISOString(),
417
+ level,
418
+ name: namespace,
419
+ msg: message,
420
+ ...data,
421
+ }
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
+ })
430
+ }
431
+
432
+ function matchesNamespaceSet(namespace: string, set: Set<string>): boolean {
433
+ if (set.has("*")) return true
434
+ for (const filter of set) {
435
+ if (namespace === filter || namespace.startsWith(filter + ":")) {
436
+ return true
437
+ }
438
+ }
439
+ return false
440
+ }
441
+
442
+ function shouldDebugNamespace(namespace: string): boolean {
443
+ if (!debugIncludes && !debugExcludes) return true
444
+ // Excludes take priority
445
+ if (debugExcludes && matchesNamespaceSet(namespace, debugExcludes)) {
446
+ return false
447
+ }
448
+ // If includes are set, namespace must match
449
+ if (debugIncludes) return matchesNamespaceSet(namespace, debugIncludes)
450
+ return true
451
+ }
452
+
453
+ /** Resolve a lazy message: if it's a function, call it; otherwise return the string */
454
+ function resolveMessage(msg: LazyMessage): string {
455
+ return typeof msg === "function" ? msg() : msg
456
+ }
457
+
458
+ function writeLog(
459
+ namespace: string,
460
+ level: OutputLogLevel,
461
+ message: LazyMessage,
462
+ data?: Record<string, unknown>,
463
+ ): void {
464
+ if (!shouldLog(level)) return
465
+ if (!shouldDebugNamespace(namespace)) return
466
+
467
+ // Resolve lazy message only after level/namespace checks pass
468
+ const resolved = resolveMessage(message)
469
+
470
+ // Auto-tag with trace/span context when context propagation is enabled
471
+ const contextTags = _getContextTags?.()
472
+ const mergedData = contextTags && Object.keys(contextTags).length > 0 ? { ...contextTags, ...data } : data
473
+
474
+ const formatted = useJsonFormat()
475
+ ? formatJSON(namespace, level, resolved, mergedData)
476
+ : formatConsole(namespace, level, resolved, mergedData)
477
+
478
+ for (const w of writers) w(formatted, level)
479
+
480
+ if (suppressConsole || outputMode === "writers-only") return
481
+
482
+ if (outputMode === "stderr") {
483
+ writeStderr(formatted)
484
+ return
485
+ }
486
+
487
+ // Default: use console methods (captured by Ink's patchConsole for TUI panel)
488
+ switch (level) {
489
+ case "trace":
490
+ case "debug":
491
+ console.debug(formatted)
492
+ break
493
+ case "info":
494
+ console.info(formatted)
495
+ break
496
+ case "warn":
497
+ console.warn(formatted)
498
+ break
499
+ case "error":
500
+ console.error(formatted)
501
+ break
502
+ }
503
+ }
504
+
505
+ function writeSpan(namespace: string, duration: number, attrs: Record<string, unknown>): void {
506
+ if (!shouldTraceNamespace(namespace)) return
507
+ if (!shouldDebugNamespace(namespace)) return
508
+
509
+ const message = `(${duration}ms)`
510
+ const formatted = useJsonFormat()
511
+ ? formatJSON(namespace, "span", message, { duration, ...attrs })
512
+ : formatConsole(namespace, "span", message, { duration, ...attrs })
513
+
514
+ for (const w of writers) w(formatted, "span")
515
+ if (!suppressConsole) writeStderr(formatted)
516
+ }
517
+
518
+ // ============ Implementation ============
519
+
520
+ interface MutableSpanData {
521
+ id: string
522
+ traceId: string
523
+ parentId: string | null
524
+ startTime: number
525
+ endTime: number | null
526
+ duration: number | null
527
+ attrs: Record<string, unknown>
528
+ }
529
+
530
+ function createLoggerImpl(
531
+ name: string,
532
+ props: Record<string, unknown>,
533
+ spanMeta: MutableSpanData | null,
534
+ parentSpanId: string | null,
535
+ traceId: string | null,
536
+ traceSampled: boolean = true,
537
+ ): Logger {
538
+ const log = (level: OutputLogLevel, msgOrError: LazyMessage | Error, data?: Record<string, unknown>): void => {
539
+ if (msgOrError instanceof Error) {
540
+ const err = msgOrError
541
+ writeLog(name, level, err.message, {
542
+ ...props,
543
+ ...data,
544
+ error_type: err.name,
545
+ error_stack: err.stack,
546
+ error_code: (err as { code?: string }).code,
547
+ })
548
+ } else {
549
+ writeLog(name, level, msgOrError, { ...props, ...data })
550
+ }
551
+ }
552
+
553
+ const logger: Logger = {
554
+ name,
555
+ props: Object.freeze({ ...props }),
556
+
557
+ get spanData(): SpanData | null {
558
+ 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
591
+ },
592
+
593
+ trace: (msg, data) => log("trace", msg, data),
594
+ debug: (msg, data) => log("debug", msg, data),
595
+ info: (msg, data) => log("info", msg, data),
596
+ warn: (msg, data) => log("warn", msg, data),
597
+ error: (msgOrError, data) => log("error", msgOrError as string, data),
598
+
599
+ logger(namespace?: string, childProps?: Record<string, unknown>): Logger {
600
+ const childName = namespace ? `${name}:${namespace}` : name
601
+ const mergedProps = { ...props, ...childProps }
602
+ return createLoggerImpl(childName, mergedProps, null, parentSpanId, traceId, traceSampled)
603
+ },
604
+
605
+ span(namespace?: string, childProps?: Record<string, unknown>): SpanLogger {
606
+ const childName = namespace ? `${name}:${namespace}` : name
607
+ const mergedProps = { ...props, ...childProps }
608
+ const newSpanId = generateSpanId()
609
+
610
+ // Resolve parent from context propagation if not explicitly set
611
+ let resolvedParentId = parentSpanId
612
+ let resolvedTraceId = traceId
613
+
614
+ if (!resolvedParentId && _getContextParent) {
615
+ const ctxParent = _getContextParent()
616
+ if (ctxParent) {
617
+ resolvedParentId = ctxParent.spanId
618
+ resolvedTraceId = resolvedTraceId || ctxParent.traceId
619
+ }
620
+ }
621
+
622
+ // Determine trace ID — generate new one if starting a new trace
623
+ const isNewTrace = !resolvedTraceId
624
+ const finalTraceId = resolvedTraceId || generateTraceId()
625
+
626
+ // Head-based sampling: inherit from parent, or decide at trace creation
627
+ const sampled = isNewTrace ? shouldSample() : traceSampled
628
+
629
+ const newSpanData: MutableSpanData = {
630
+ id: newSpanId,
631
+ traceId: finalTraceId,
632
+ parentId: resolvedParentId,
633
+ startTime: Date.now(),
634
+ endTime: null,
635
+ duration: null,
636
+ attrs: {},
637
+ }
638
+
639
+ const spanLogger = createLoggerImpl(
640
+ childName,
641
+ mergedProps,
642
+ newSpanData,
643
+ newSpanId,
644
+ finalTraceId,
645
+ sampled,
646
+ ) as SpanLogger
647
+
648
+ // Enter span context for async propagation (if enabled)
649
+ _enterContext?.(newSpanId, finalTraceId, resolvedParentId)
650
+
651
+ // Add disposal
652
+ ;(spanLogger as unknown as { [Symbol.dispose]: () => void })[Symbol.dispose] = () => {
653
+ if (newSpanData.endTime !== null) return // Already disposed
654
+
655
+ newSpanData.endTime = Date.now()
656
+ newSpanData.duration = newSpanData.endTime - newSpanData.startTime
657
+
658
+ // Exit span context (restore parent or clear)
659
+ _exitContext?.(resolvedParentId, resolvedParentId ? finalTraceId : null)
660
+
661
+ // Only emit span if sampled
662
+ if (sampled) {
663
+ writeSpan(childName, newSpanData.duration, {
664
+ span_id: newSpanData.id,
665
+ trace_id: newSpanData.traceId,
666
+ parent_id: newSpanData.parentId,
667
+ ...mergedProps,
668
+ ...newSpanData.attrs,
669
+ })
670
+ }
671
+ }
672
+
673
+ return spanLogger
674
+ },
675
+
676
+ child(context: string | Record<string, unknown>): Logger {
677
+ if (typeof context === "string") {
678
+ // Deprecated string overload - use .logger() instead
679
+ return this.logger(context)
680
+ }
681
+ // Context object overload: merge context fields into props
682
+ return createLoggerImpl(name, { ...props, ...context }, null, parentSpanId, traceId, traceSampled)
683
+ },
684
+
685
+ end(): void {
686
+ if (spanMeta?.endTime === null) {
687
+ ;(this as unknown as { [Symbol.dispose]: () => void })[Symbol.dispose]?.()
688
+ }
689
+ },
690
+ }
691
+
692
+ return logger
693
+ }
694
+
695
+ /**
696
+ * Create a plain logger for a component (internal use).
697
+ * For application code, use createLogger() instead which returns undefined for disabled levels.
698
+ */
699
+ function createPlainLogger(name: string, props?: Record<string, unknown>): Logger {
700
+ return createLoggerImpl(name, props || {}, null, null, null)
701
+ }
702
+
703
+ // ============ Collected Spans (for analysis) ============
704
+
705
+ const collectedSpans: SpanData[] = []
706
+ let collectSpans = false
707
+
708
+ /** Enable span collection for analysis */
709
+ export function startCollecting(): void {
710
+ collectSpans = true
711
+ collectedSpans.length = 0
712
+ }
713
+
714
+ /** Stop collecting and return collected spans */
715
+ export function stopCollecting(): SpanData[] {
716
+ collectSpans = false
717
+ return [...collectedSpans]
718
+ }
719
+
720
+ /** Get collected spans */
721
+ export function getCollectedSpans(): SpanData[] {
722
+ return [...collectedSpans]
723
+ }
724
+
725
+ /** Clear collected spans */
726
+ export function clearCollectedSpans(): void {
727
+ collectedSpans.length = 0
728
+ }
729
+
730
+ // ============ Conditional Logger (Zero-Overhead Pattern) ============
731
+
732
+ /**
733
+ * Logger with optional methods — returns undefined for disabled levels.
734
+ * Use with optional chaining: `log.debug?.("msg")` for zero-overhead when disabled.
735
+ *
736
+ * Defined as an explicit interface (not Omit<Logger,...>) so that
737
+ * oxlint's type-aware mode can resolve it without advanced type inference.
738
+ */
739
+ export interface ConditionalLogger {
740
+ readonly name: string
741
+ readonly props: Readonly<Record<string, unknown>>
742
+ readonly spanData: SpanData | null
743
+
744
+ trace?: (message: LazyMessage, data?: Record<string, unknown>) => void
745
+ debug?: (message: LazyMessage, data?: Record<string, unknown>) => void
746
+ info?: (message: LazyMessage, data?: Record<string, unknown>) => void
747
+ warn?: (message: LazyMessage, data?: Record<string, unknown>) => void
748
+ error?: {
749
+ (message: LazyMessage, data?: Record<string, unknown>): void
750
+ (error: Error, data?: Record<string, unknown>): void
751
+ }
752
+
753
+ logger(namespace?: string, props?: Record<string, unknown>): Logger
754
+ span(namespace?: string, props?: Record<string, unknown>): SpanLogger
755
+ child(context: Record<string, unknown>): Logger
756
+ child(context: string): Logger
757
+ end(): void
758
+ }
759
+
760
+ /**
761
+ * Create a logger for a component.
762
+ * Returns undefined for disabled levels - use with optional chaining for zero overhead.
763
+ *
764
+ * Log levels (most → least verbose): trace < debug < info < warn < error < silent
765
+ * Default level: info (trace and debug disabled)
766
+ *
767
+ * @example
768
+ * const log = createLogger('myapp')
769
+ *
770
+ * // All methods support ?. for zero-overhead when disabled
771
+ * log.trace?.(`very verbose: ${expensiveDebug()}`) // Skipped at info level
772
+ * log.debug?.(`debug: ${getState()}`) // Skipped at info level
773
+ * log.info?.('starting') // Enabled at info level
774
+ * log.warn?.('deprecated') // Enabled at info level
775
+ * log.error?.('failed') // Enabled at info level
776
+ *
777
+ * // With -q flag or LOG_LEVEL=warn:
778
+ * log.info?.('starting') // Now skipped - info < warn
779
+ *
780
+ * // With initial props
781
+ * const log = createLogger('myapp', { version: '1.0' })
782
+ *
783
+ * // Create spans
784
+ * {
785
+ * using task = log.span('import', { file: 'data.csv' })
786
+ * task.info?.('importing')
787
+ * task.spanData.count = 42
788
+ * }
789
+ */
790
+ export function createLogger(name: string, props?: Record<string, unknown>): ConditionalLogger {
791
+ const baseLog = createPlainLogger(name, props)
792
+
793
+ return new Proxy(baseLog as ConditionalLogger, {
794
+ get(target, prop: string) {
795
+ if (prop in LOG_LEVEL_PRIORITY && prop !== "silent") {
796
+ const current = LOG_LEVEL_PRIORITY[currentLogLevel]
797
+ if (LOG_LEVEL_PRIORITY[prop as keyof typeof LOG_LEVEL_PRIORITY] < current) {
798
+ return undefined
799
+ }
800
+ }
801
+ return (target as unknown as Record<string, unknown>)[prop]
802
+ },
803
+ })
804
+ }