loggily 0.0.1 → 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.
package/src/core.ts ADDED
@@ -0,0 +1,850 @@
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
+
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) {
191
+ if (part.startsWith("-")) {
192
+ excludeList.push(part.slice(1))
193
+ } else {
194
+ includeList.push(part)
195
+ }
196
+ }
197
+ return {
198
+ includes: includeList.length > 0 ? new Set(includeList) : null,
199
+ excludes: excludeList.length > 0 ? new Set(excludeList) : null,
200
+ }
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(["*"])
213
+ }
214
+ debugExcludes = parsed.excludes
215
+ // Auto-lower log level to at least debug when DEBUG is set
216
+ if (LOG_LEVEL_PRIORITY[currentLogLevel] > LOG_LEVEL_PRIORITY.debug) {
217
+ currentLogLevel = "debug"
218
+ }
219
+ }
220
+
221
+ /** Set minimum log level */
222
+ export function setLogLevel(level: LogLevel): void {
223
+ currentLogLevel = level
224
+ }
225
+
226
+ /** Get current log level */
227
+ export function getLogLevel(): LogLevel {
228
+ return currentLogLevel
229
+ }
230
+
231
+ /** Enable span output */
232
+ export function enableSpans(): void {
233
+ spansEnabled = true
234
+ }
235
+
236
+ /** Disable span output */
237
+ export function disableSpans(): void {
238
+ spansEnabled = false
239
+ }
240
+
241
+ /** Check if spans are enabled */
242
+ export function spansAreEnabled(): boolean {
243
+ return spansEnabled
244
+ }
245
+
246
+ /**
247
+ * Set trace filter for namespace-based span output control.
248
+ * Only spans matching these namespace prefixes will be output.
249
+ * @param namespaces - Array of namespace prefixes, or null to disable filtering
250
+ */
251
+ export function setTraceFilter(namespaces: string[] | null): void {
252
+ if (namespaces === null || namespaces.length === 0) {
253
+ traceFilter = null
254
+ } else {
255
+ traceFilter = new Set(namespaces)
256
+ spansEnabled = true
257
+ }
258
+ }
259
+
260
+ /** Get current trace filter (null means no filtering) */
261
+ export function getTraceFilter(): string[] | null {
262
+ return traceFilter ? [...traceFilter] : null
263
+ }
264
+
265
+ /**
266
+ * Set debug namespace filter (like the `debug` npm package).
267
+ * When set, only loggers matching these namespace prefixes produce output.
268
+ * Supports negative patterns with `-` prefix (e.g., ["-km:noisy"]).
269
+ * Also ensures log level is at least `debug`.
270
+ * @param namespaces - Array of namespace prefixes (prefix with `-` to exclude), or null to disable
271
+ */
272
+ export function setDebugFilter(namespaces: string[] | null): void {
273
+ if (namespaces === null || namespaces.length === 0) {
274
+ debugIncludes = null
275
+ debugExcludes = null
276
+ } else {
277
+ const parsed = parseNamespaceFilter(namespaces)
278
+ debugIncludes = parsed.includes
279
+ debugExcludes = parsed.excludes
280
+ if (LOG_LEVEL_PRIORITY[currentLogLevel] > LOG_LEVEL_PRIORITY.debug) {
281
+ currentLogLevel = "debug"
282
+ }
283
+ }
284
+ }
285
+
286
+ /** Get current debug namespace filter (null means no filtering) */
287
+ export function getDebugFilter(): string[] | null {
288
+ if (!debugIncludes && !debugExcludes) return null
289
+ const result: string[] = []
290
+ if (debugIncludes) result.push(...debugIncludes)
291
+ if (debugExcludes) result.push(...[...debugExcludes].map((e) => `-${e}`))
292
+ return result
293
+ }
294
+
295
+ // ============ Log Format ============
296
+
297
+ /** Output format: human-readable console or structured JSON */
298
+ export type LogFormat = "console" | "json"
299
+
300
+ // Initialize from LOG_FORMAT env var, falling back to auto-detect
301
+ const envLogFormat = getEnv("LOG_FORMAT")?.toLowerCase()
302
+ let currentLogFormat: LogFormat = envLogFormat === "json" ? "json" : envLogFormat === "console" ? "console" : "console"
303
+
304
+ /** Set log output format */
305
+ export function setLogFormat(format: LogFormat): void {
306
+ currentLogFormat = format
307
+ }
308
+
309
+ /** Get current log output format */
310
+ export function getLogFormat(): LogFormat {
311
+ return currentLogFormat
312
+ }
313
+
314
+ /** Determine whether to use JSON formatting for the current call */
315
+ function useJsonFormat(): boolean {
316
+ return currentLogFormat === "json" || getEnv("NODE_ENV") === "production" || getEnv("TRACE_FORMAT") === "json"
317
+ }
318
+
319
+ // ============ ID Generation (delegated to tracing.ts) ============
320
+
321
+ import { generateSpanId, generateTraceId, resetIdCounters, shouldSample } from "./tracing.js"
322
+
323
+ // Reset for testing
324
+ export function resetIds(): void {
325
+ resetIdCounters()
326
+ }
327
+
328
+ // ============ Context Propagation Hooks ============
329
+
330
+ // These are set by context.ts when enableContextPropagation() is called.
331
+ // Kept as nullable callbacks to avoid importing AsyncLocalStorage in browser.
332
+
333
+ /** Hook to get current span context tags (trace_id, span_id) for auto-tagging logs */
334
+ let _getContextTags: (() => Record<string, string>) | null = null
335
+
336
+ /** Hook to get parent span info from async context */
337
+ let _getContextParent: (() => { spanId: string; traceId: string } | null) | null = null
338
+
339
+ /** Hook to enter a span context (sets AsyncLocalStorage for the current async scope) */
340
+ let _enterContext: ((spanId: string, traceId: string, parentId: string | null) => void) | null = null
341
+
342
+ /** Hook to exit a span context (restores previous context snapshot) */
343
+ let _exitContext: ((spanId: string) => void) | null = null
344
+
345
+ /**
346
+ * Register context propagation hooks (called by context.ts).
347
+ * @internal
348
+ */
349
+ export function _setContextHooks(hooks: {
350
+ getContextTags: () => Record<string, string>
351
+ getContextParent: () => { spanId: string; traceId: string } | null
352
+ enterContext: (spanId: string, traceId: string, parentId: string | null) => void
353
+ exitContext: (spanId: string) => void
354
+ }): void {
355
+ _getContextTags = hooks.getContextTags
356
+ _getContextParent = hooks.getContextParent
357
+ _enterContext = hooks.enterContext
358
+ _exitContext = hooks.exitContext
359
+ }
360
+
361
+ /**
362
+ * Clear context propagation hooks (called by disableContextPropagation).
363
+ * @internal
364
+ */
365
+ export function _clearContextHooks(): void {
366
+ _getContextTags = null
367
+ _getContextParent = null
368
+ _enterContext = null
369
+ _exitContext = null
370
+ }
371
+
372
+ // ============ Formatting ============
373
+
374
+ function shouldLog(level: LogLevel): boolean {
375
+ return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[currentLogLevel]
376
+ }
377
+
378
+ function shouldTraceNamespace(namespace: string): boolean {
379
+ if (!spansEnabled) return false
380
+ if (!traceFilter) return true
381
+ return matchesNamespaceSet(namespace, traceFilter)
382
+ }
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
+
402
+ function formatConsole(namespace: string, level: string, message: string, data?: Record<string, unknown>): string {
403
+ const time = pc.dim(new Date().toISOString().split("T")[1]?.split(".")[0] || "")
404
+
405
+ let levelStr = ""
406
+ switch (level) {
407
+ case "trace":
408
+ levelStr = pc.dim("TRACE")
409
+ break
410
+ case "debug":
411
+ levelStr = pc.dim("DEBUG")
412
+ break
413
+ case "info":
414
+ levelStr = pc.blue("INFO")
415
+ break
416
+ case "warn":
417
+ levelStr = pc.yellow("WARN")
418
+ break
419
+ case "error":
420
+ levelStr = pc.red("ERROR")
421
+ break
422
+ case "span":
423
+ levelStr = pc.magenta("SPAN")
424
+ break
425
+ }
426
+
427
+ const ns = pc.cyan(namespace)
428
+ let output = `${time} ${levelStr} ${ns} ${message}`
429
+
430
+ if (data && Object.keys(data).length > 0) {
431
+ output += ` ${pc.dim(safeStringify(data))}`
432
+ }
433
+
434
+ return output
435
+ }
436
+
437
+ function formatJSON(namespace: string, level: string, message: string, data?: Record<string, unknown>): string {
438
+ const entry = {
439
+ time: new Date().toISOString(),
440
+ level,
441
+ name: namespace,
442
+ msg: message,
443
+ ...data,
444
+ }
445
+ return safeStringify(entry)
446
+ }
447
+
448
+ function matchesNamespaceSet(namespace: string, set: Set<string>): boolean {
449
+ if (set.has("*")) return true
450
+ for (const filter of set) {
451
+ if (namespace === filter || namespace.startsWith(filter + ":")) {
452
+ return true
453
+ }
454
+ }
455
+ return false
456
+ }
457
+
458
+ function shouldDebugNamespace(namespace: string): boolean {
459
+ if (!debugIncludes && !debugExcludes) return true
460
+ // Excludes take priority
461
+ if (debugExcludes && matchesNamespaceSet(namespace, debugExcludes)) {
462
+ return false
463
+ }
464
+ // If includes are set, namespace must match
465
+ if (debugIncludes) return matchesNamespaceSet(namespace, debugIncludes)
466
+ return true
467
+ }
468
+
469
+ /** Resolve a lazy message: if it's a function, call it; otherwise return the string */
470
+ function resolveMessage(msg: LazyMessage): string {
471
+ return typeof msg === "function" ? msg() : msg
472
+ }
473
+
474
+ function writeLog(
475
+ namespace: string,
476
+ level: OutputLogLevel,
477
+ message: LazyMessage,
478
+ data?: Record<string, unknown>,
479
+ ): void {
480
+ if (!shouldLog(level)) return
481
+ if (!shouldDebugNamespace(namespace)) return
482
+
483
+ // Resolve lazy message only after level/namespace checks pass
484
+ const resolved = resolveMessage(message)
485
+
486
+ // Auto-tag with trace/span context when context propagation is enabled
487
+ const contextTags = _getContextTags?.()
488
+ const mergedData = contextTags && Object.keys(contextTags).length > 0 ? { ...contextTags, ...data } : data
489
+
490
+ const formatted = useJsonFormat()
491
+ ? formatJSON(namespace, level, resolved, mergedData)
492
+ : formatConsole(namespace, level, resolved, mergedData)
493
+
494
+ for (const w of writers) w(formatted, level)
495
+
496
+ if (suppressConsole || outputMode === "writers-only") return
497
+
498
+ if (outputMode === "stderr") {
499
+ writeStderr(formatted)
500
+ return
501
+ }
502
+
503
+ // Default: use console methods (captured by Ink's patchConsole for TUI panel)
504
+ switch (level) {
505
+ case "trace":
506
+ case "debug":
507
+ console.debug(formatted)
508
+ break
509
+ case "info":
510
+ console.info(formatted)
511
+ break
512
+ case "warn":
513
+ console.warn(formatted)
514
+ break
515
+ case "error":
516
+ console.error(formatted)
517
+ break
518
+ }
519
+ }
520
+
521
+ export function writeSpan(namespace: string, duration: number, attrs: Record<string, unknown>): void {
522
+ if (!shouldTraceNamespace(namespace)) return
523
+ if (!shouldDebugNamespace(namespace)) return
524
+
525
+ const message = `(${duration}ms)`
526
+ const formatted = useJsonFormat()
527
+ ? formatJSON(namespace, "span", message, { duration, ...attrs })
528
+ : formatConsole(namespace, "span", message, { duration, ...attrs })
529
+
530
+ for (const w of writers) w(formatted, "span")
531
+ if (!suppressConsole) writeStderr(formatted)
532
+ }
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
+
568
+ // ============ Implementation ============
569
+
570
+ interface MutableSpanData {
571
+ id: string
572
+ traceId: string
573
+ parentId: string | null
574
+ startTime: number
575
+ endTime: number | null
576
+ duration: number | null
577
+ attrs: Record<string, unknown>
578
+ }
579
+
580
+ function createLoggerImpl(
581
+ name: string,
582
+ props: Record<string, unknown>,
583
+ spanMeta: MutableSpanData | null,
584
+ parentSpanId: string | null,
585
+ traceId: string | null,
586
+ traceSampled: boolean = true,
587
+ ): Logger {
588
+ const log = (level: OutputLogLevel, msgOrError: LazyMessage | Error, data?: Record<string, unknown>): void => {
589
+ if (msgOrError instanceof Error) {
590
+ const err = msgOrError
591
+ writeLog(name, level, err.message, {
592
+ ...props,
593
+ ...data,
594
+ error_type: err.name,
595
+ error_stack: err.stack,
596
+ error_code: (err as { code?: string }).code,
597
+ })
598
+ } else {
599
+ writeLog(name, level, msgOrError, { ...props, ...data })
600
+ }
601
+ }
602
+
603
+ const logger: Logger = {
604
+ name,
605
+ props: Object.freeze({ ...props }),
606
+
607
+ get spanData(): SpanData | null {
608
+ if (!spanMeta) return null
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
+ )
620
+ },
621
+
622
+ trace: (msg, data) => log("trace", msg, data),
623
+ debug: (msg, data) => log("debug", msg, data),
624
+ info: (msg, data) => log("info", msg, data),
625
+ warn: (msg, data) => log("warn", msg, data),
626
+ error: (msgOrError, data) => log("error", msgOrError as string, data),
627
+
628
+ logger(namespace?: string, childProps?: Record<string, unknown>): Logger {
629
+ const childName = namespace ? `${name}:${namespace}` : name
630
+ const mergedProps = { ...props, ...childProps }
631
+ return createLoggerImpl(childName, mergedProps, null, parentSpanId, traceId, traceSampled)
632
+ },
633
+
634
+ span(namespace?: string, childProps?: Record<string, unknown>): SpanLogger {
635
+ const childName = namespace ? `${name}:${namespace}` : name
636
+ const mergedProps = { ...props, ...childProps }
637
+ const newSpanId = generateSpanId()
638
+
639
+ // Resolve parent from context propagation if not explicitly set
640
+ let resolvedParentId = parentSpanId
641
+ let resolvedTraceId = traceId
642
+
643
+ if (!resolvedParentId && _getContextParent) {
644
+ const ctxParent = _getContextParent()
645
+ if (ctxParent) {
646
+ resolvedParentId = ctxParent.spanId
647
+ resolvedTraceId = resolvedTraceId || ctxParent.traceId
648
+ }
649
+ }
650
+
651
+ // Determine trace ID — generate new one if starting a new trace
652
+ const isNewTrace = !resolvedTraceId
653
+ const finalTraceId = resolvedTraceId || generateTraceId()
654
+
655
+ // Head-based sampling: inherit from parent, or decide at trace creation
656
+ const sampled = isNewTrace ? shouldSample() : traceSampled
657
+
658
+ const newSpanData: MutableSpanData = {
659
+ id: newSpanId,
660
+ traceId: finalTraceId,
661
+ parentId: resolvedParentId,
662
+ startTime: Date.now(),
663
+ endTime: null,
664
+ duration: null,
665
+ attrs: {},
666
+ }
667
+
668
+ const spanLogger = createLoggerImpl(
669
+ childName,
670
+ mergedProps,
671
+ newSpanData,
672
+ newSpanId,
673
+ finalTraceId,
674
+ sampled,
675
+ ) as SpanLogger
676
+
677
+ // Enter span context for async propagation (if enabled)
678
+ _enterContext?.(newSpanId, finalTraceId, resolvedParentId)
679
+
680
+ // Add disposal
681
+ ;(spanLogger as unknown as { [Symbol.dispose]: () => void })[Symbol.dispose] = () => {
682
+ if (newSpanData.endTime !== null) return // Already disposed
683
+
684
+ newSpanData.endTime = Date.now()
685
+ newSpanData.duration = newSpanData.endTime - newSpanData.startTime
686
+
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)
706
+
707
+ // Only emit span if sampled
708
+ if (sampled) {
709
+ writeSpan(childName, newSpanData.duration, {
710
+ span_id: newSpanData.id,
711
+ trace_id: newSpanData.traceId,
712
+ parent_id: newSpanData.parentId,
713
+ ...mergedProps,
714
+ ...newSpanData.attrs,
715
+ })
716
+ }
717
+ }
718
+
719
+ return spanLogger
720
+ },
721
+
722
+ child(context: string | Record<string, unknown>): Logger {
723
+ if (typeof context === "string") {
724
+ // Deprecated string overload - use .logger() instead
725
+ return this.logger(context)
726
+ }
727
+ // Context object overload: merge context fields into props
728
+ return createLoggerImpl(name, { ...props, ...context }, null, parentSpanId, traceId, traceSampled)
729
+ },
730
+
731
+ end(): void {
732
+ if (spanMeta?.endTime === null) {
733
+ ;(this as unknown as { [Symbol.dispose]: () => void })[Symbol.dispose]?.()
734
+ }
735
+ },
736
+ }
737
+
738
+ return logger
739
+ }
740
+
741
+ /**
742
+ * Create a plain logger for a component (internal use).
743
+ * For application code, use createLogger() instead which returns undefined for disabled levels.
744
+ */
745
+ function createPlainLogger(name: string, props?: Record<string, unknown>): Logger {
746
+ return createLoggerImpl(name, props || {}, null, null, null)
747
+ }
748
+
749
+ // ============ Collected Spans (for analysis) ============
750
+
751
+ const collectedSpans: SpanData[] = []
752
+ let collectSpans = false
753
+
754
+ /** Enable span collection for analysis */
755
+ export function startCollecting(): void {
756
+ collectSpans = true
757
+ collectedSpans.length = 0
758
+ }
759
+
760
+ /** Stop collecting and return collected spans */
761
+ export function stopCollecting(): SpanData[] {
762
+ collectSpans = false
763
+ return [...collectedSpans]
764
+ }
765
+
766
+ /** Get collected spans */
767
+ export function getCollectedSpans(): SpanData[] {
768
+ return [...collectedSpans]
769
+ }
770
+
771
+ /** Clear collected spans */
772
+ export function clearCollectedSpans(): void {
773
+ collectedSpans.length = 0
774
+ }
775
+
776
+ // ============ Conditional Logger (Zero-Overhead Pattern) ============
777
+
778
+ /**
779
+ * Logger with optional methods — returns undefined for disabled levels.
780
+ * Use with optional chaining: `log.debug?.("msg")` for zero-overhead when disabled.
781
+ *
782
+ * Defined as an explicit interface (not Omit<Logger,...>) so that
783
+ * oxlint's type-aware mode can resolve it without advanced type inference.
784
+ */
785
+ export interface ConditionalLogger {
786
+ readonly name: string
787
+ readonly props: Readonly<Record<string, unknown>>
788
+ readonly spanData: SpanData | null
789
+
790
+ trace?: (message: LazyMessage, data?: Record<string, unknown>) => void
791
+ debug?: (message: LazyMessage, data?: Record<string, unknown>) => void
792
+ info?: (message: LazyMessage, data?: Record<string, unknown>) => void
793
+ warn?: (message: LazyMessage, data?: Record<string, unknown>) => void
794
+ error?: {
795
+ (message: LazyMessage, data?: Record<string, unknown>): void
796
+ (error: Error, data?: Record<string, unknown>): void
797
+ }
798
+
799
+ logger(namespace?: string, props?: Record<string, unknown>): Logger
800
+ span(namespace?: string, props?: Record<string, unknown>): SpanLogger
801
+ child(context: Record<string, unknown>): Logger
802
+ child(context: string): Logger
803
+ end(): void
804
+ }
805
+
806
+ /**
807
+ * Create a logger for a component.
808
+ * Returns undefined for disabled levels - use with optional chaining for zero overhead.
809
+ *
810
+ * Log levels (most → least verbose): trace < debug < info < warn < error < silent
811
+ * Default level: info (trace and debug disabled)
812
+ *
813
+ * @example
814
+ * const log = createLogger('myapp')
815
+ *
816
+ * // All methods support ?. for zero-overhead when disabled
817
+ * log.trace?.(`very verbose: ${expensiveDebug()}`) // Skipped at info level
818
+ * log.debug?.(`debug: ${getState()}`) // Skipped at info level
819
+ * log.info?.('starting') // Enabled at info level
820
+ * log.warn?.('deprecated') // Enabled at info level
821
+ * log.error?.('failed') // Enabled at info level
822
+ *
823
+ * // With -q flag or LOG_LEVEL=warn:
824
+ * log.info?.('starting') // Now skipped - info < warn
825
+ *
826
+ * // With initial props
827
+ * const log = createLogger('myapp', { version: '1.0' })
828
+ *
829
+ * // Create spans
830
+ * {
831
+ * using task = log.span('import', { file: 'data.csv' })
832
+ * task.info?.('importing')
833
+ * task.spanData.count = 42
834
+ * }
835
+ */
836
+ export function createLogger(name: string, props?: Record<string, unknown>): ConditionalLogger {
837
+ const baseLog = createPlainLogger(name, props)
838
+
839
+ return new Proxy(baseLog as ConditionalLogger, {
840
+ get(target, prop: string) {
841
+ if (prop in LOG_LEVEL_PRIORITY && prop !== "silent") {
842
+ const current = LOG_LEVEL_PRIORITY[currentLogLevel]
843
+ if (LOG_LEVEL_PRIORITY[prop as keyof typeof LOG_LEVEL_PRIORITY] < current) {
844
+ return undefined
845
+ }
846
+ }
847
+ return (target as unknown as Record<string, unknown>)[prop]
848
+ },
849
+ })
850
+ }