loggily 0.6.0 → 0.6.2

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