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/worker.ts ADDED
@@ -0,0 +1,674 @@
1
+ /**
2
+ * Worker Thread Logger/Console Forwarding
3
+ *
4
+ * Provides utilities to forward loggily and console.* output from worker threads
5
+ * to the main thread, ensuring proper integration with DEBUG_LOG and log files.
6
+ *
7
+ * ## Full Logger Forwarding (Recommended)
8
+ *
9
+ * @example Worker side:
10
+ * ```typescript
11
+ * import { createWorkerLogger } from "loggily/worker"
12
+ * const log = createWorkerLogger(postMessage, "km:worker:parse")
13
+ *
14
+ * log.info("processing", { file: "test.md" })
15
+ * {
16
+ * using span = log.span("parse")
17
+ * // ... work ...
18
+ * span.spanData.lines = 100
19
+ * }
20
+ * ```
21
+ *
22
+ * @example Main thread side:
23
+ * ```typescript
24
+ * import { createWorkerLogHandler } from "loggily/worker"
25
+ *
26
+ * const handleLog = createWorkerLogHandler()
27
+ * worker.onmessage = (e) => {
28
+ * if (e.data.type === "log" || e.data.type === "span") handleLog(e.data)
29
+ * }
30
+ * ```
31
+ *
32
+ * ## Console Forwarding (Simple)
33
+ *
34
+ * @example Worker side:
35
+ * ```typescript
36
+ * import { forwardConsole } from "loggily/worker"
37
+ * forwardConsole(postMessage)
38
+ *
39
+ * console.log("message") // Forwarded to main thread
40
+ * ```
41
+ */
42
+
43
+ import {
44
+ createLogger,
45
+ createSpanDataProxy,
46
+ enableSpans,
47
+ writeSpan,
48
+ type ConditionalLogger,
49
+ type LazyMessage,
50
+ type Logger,
51
+ type SpanLogger,
52
+ type SpanData,
53
+ } from "./index.ts"
54
+
55
+ // ============ Message Protocol ============
56
+
57
+ /** Message sent from worker to main thread for console output */
58
+ export interface WorkerConsoleMessage {
59
+ type: "console"
60
+ level: "log" | "debug" | "info" | "warn" | "error" | "trace"
61
+ namespace?: string
62
+ args: unknown[]
63
+ timestamp: number
64
+ }
65
+
66
+ /** Message sent from worker to main thread for structured log output */
67
+ export interface WorkerLogMessage {
68
+ type: "log"
69
+ level: "trace" | "debug" | "info" | "warn" | "error"
70
+ namespace: string
71
+ message: string
72
+ data?: Record<string, unknown>
73
+ timestamp: number
74
+ }
75
+
76
+ /** Message sent from worker to main thread for span events */
77
+ export interface WorkerSpanMessage {
78
+ type: "span"
79
+ event: "start" | "end"
80
+ namespace: string
81
+ spanId: string
82
+ traceId: string
83
+ parentId: string | null
84
+ startTime: number
85
+ endTime?: number
86
+ duration?: number
87
+ props: Record<string, unknown>
88
+ spanData: Record<string, unknown>
89
+ timestamp: number
90
+ }
91
+
92
+ /** Union type for all worker messages */
93
+ export type WorkerMessage = WorkerConsoleMessage | WorkerLogMessage | WorkerSpanMessage
94
+
95
+ /** Type guard for WorkerConsoleMessage */
96
+ export function isWorkerConsoleMessage(msg: unknown): msg is WorkerConsoleMessage {
97
+ return (
98
+ typeof msg === "object" &&
99
+ (msg as WorkerConsoleMessage)?.type === "console" &&
100
+ typeof (msg as WorkerConsoleMessage).level === "string" &&
101
+ Array.isArray((msg as WorkerConsoleMessage).args)
102
+ )
103
+ }
104
+
105
+ /** Type guard for WorkerLogMessage */
106
+ export function isWorkerLogMessage(msg: unknown): msg is WorkerLogMessage {
107
+ return (
108
+ typeof msg === "object" &&
109
+ (msg as WorkerLogMessage)?.type === "log" &&
110
+ typeof (msg as WorkerLogMessage).level === "string" &&
111
+ typeof (msg as WorkerLogMessage).namespace === "string"
112
+ )
113
+ }
114
+
115
+ /** Type guard for WorkerSpanMessage */
116
+ export function isWorkerSpanMessage(msg: unknown): msg is WorkerSpanMessage {
117
+ return (
118
+ typeof msg === "object" &&
119
+ (msg as WorkerSpanMessage)?.type === "span" &&
120
+ typeof (msg as WorkerSpanMessage).event === "string"
121
+ )
122
+ }
123
+
124
+ /** Type guard for any worker message */
125
+ export function isWorkerMessage(msg: unknown): msg is WorkerMessage {
126
+ return isWorkerConsoleMessage(msg) || isWorkerLogMessage(msg) || isWorkerSpanMessage(msg)
127
+ }
128
+
129
+ // ============ Worker Side ============
130
+
131
+ type PostMessageFn = (message: WorkerConsoleMessage) => void
132
+
133
+ /** Store original console methods for restoration */
134
+ let originalConsole: typeof console | null = null
135
+
136
+ /**
137
+ * Serialize a value for transmission via postMessage.
138
+ * Handles non-serializable values like functions and circular references.
139
+ */
140
+ function serializeArg(arg: unknown, depth = 0): unknown {
141
+ // Prevent infinite recursion
142
+ if (depth > 5) return "[max depth]"
143
+
144
+ if (arg === null || arg === undefined) return arg
145
+ if (typeof arg === "function") return `[Function: ${arg.name || "anonymous"}]`
146
+ if (typeof arg === "symbol") return arg.toString()
147
+ if (typeof arg === "bigint") return arg.toString() + "n"
148
+
149
+ if (arg instanceof Error) {
150
+ return {
151
+ name: arg.name,
152
+ message: arg.message,
153
+ stack: arg.stack,
154
+ }
155
+ }
156
+
157
+ if (Array.isArray(arg)) {
158
+ return arg.map((v) => serializeArg(v, depth + 1))
159
+ }
160
+
161
+ if (typeof arg === "object") {
162
+ try {
163
+ // Try structured clone first (handles most cases)
164
+ structuredClone(arg)
165
+ return arg
166
+ } catch {
167
+ // Fall back to manual serialization
168
+ const result: Record<string, unknown> = {}
169
+ const seen = new Set<object>()
170
+ seen.add(arg)
171
+
172
+ for (const [key, value] of Object.entries(arg)) {
173
+ if (typeof value === "object" && value !== null && seen.has(value)) {
174
+ result[key] = "[Circular]"
175
+ } else {
176
+ result[key] = serializeArg(value, depth + 1)
177
+ }
178
+ }
179
+ return result
180
+ }
181
+ }
182
+
183
+ return arg
184
+ }
185
+
186
+ /**
187
+ * Forward console.* calls from worker to main thread.
188
+ *
189
+ * Monkey-patches console methods to send messages via postMessage.
190
+ * Call this at the start of your worker script.
191
+ *
192
+ * @param postMessage - The worker's postMessage function
193
+ * @param namespace - Optional namespace for log messages (e.g., "km:worker:parse")
194
+ *
195
+ * @example
196
+ * ```typescript
197
+ * // At top of worker file:
198
+ * import { forwardConsole } from "loggily/worker"
199
+ * forwardConsole(postMessage, "km:worker:parse")
200
+ *
201
+ * // Now all console.* calls are forwarded:
202
+ * console.log("processing", { file: "test.md" })
203
+ * console.error(new Error("failed"))
204
+ * ```
205
+ */
206
+ export function forwardConsole(postMessage: PostMessageFn, namespace?: string): void {
207
+ // Store original console for restoration
208
+ if (!originalConsole) {
209
+ originalConsole = { ...console }
210
+ }
211
+
212
+ const levels = ["log", "debug", "info", "warn", "error", "trace"] as const
213
+
214
+ for (const level of levels) {
215
+ console[level] = (...args: unknown[]) => {
216
+ const serializedArgs = args.map((arg) => serializeArg(arg))
217
+
218
+ try {
219
+ postMessage({
220
+ type: "console",
221
+ level,
222
+ namespace,
223
+ args: serializedArgs,
224
+ timestamp: Date.now(),
225
+ })
226
+ } catch {
227
+ // postMessage might fail if worker is shutting down
228
+ // Fall back to original console
229
+ originalConsole?.[level](...args)
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Restore original console methods.
237
+ * Call this if you need to disable console forwarding.
238
+ */
239
+ export function restoreConsole(): void {
240
+ if (originalConsole) {
241
+ Object.assign(console, originalConsole)
242
+ originalConsole = null
243
+ }
244
+ }
245
+
246
+ // ============ Worker Logger (Full API) ============
247
+
248
+ type PostMessageAnyFn = (message: WorkerMessage) => void
249
+
250
+ let workerSpanIdCounter = 0
251
+ let workerTraceIdCounter = 0
252
+
253
+ function generateWorkerSpanId(): string {
254
+ return `wsp_${(++workerSpanIdCounter).toString(36)}`
255
+ }
256
+
257
+ function generateWorkerTraceId(): string {
258
+ return `wtr_${(++workerTraceIdCounter).toString(36)}`
259
+ }
260
+
261
+ /** Reset worker ID counters (for testing) */
262
+ export function resetWorkerIds(): void {
263
+ workerSpanIdCounter = 0
264
+ workerTraceIdCounter = 0
265
+ }
266
+
267
+ interface WorkerLoggerOptions {
268
+ /** Parent span ID for nested spans */
269
+ parentSpanId?: string | null
270
+ /** Trace ID for distributed tracing */
271
+ traceId?: string | null
272
+ }
273
+
274
+ /**
275
+ * Create a logger instance for use in a worker thread.
276
+ *
277
+ * All log calls and span events are forwarded to the main thread via postMessage.
278
+ * The main thread should use createWorkerLogHandler to process these messages.
279
+ *
280
+ * @param postMessage - The worker's postMessage function
281
+ * @param namespace - Logger namespace (e.g., "km:worker:parse")
282
+ * @param props - Optional initial props
283
+ * @param options - Optional configuration
284
+ *
285
+ * @example
286
+ * ```typescript
287
+ * import { createWorkerLogger } from "loggily/worker"
288
+ *
289
+ * const log = createWorkerLogger(postMessage, "km:worker:parse")
290
+ *
291
+ * log.info("starting parse", { file: "test.md" })
292
+ *
293
+ * {
294
+ * using span = log.span("process")
295
+ * span.info("processing...")
296
+ * span.spanData.lineCount = 100
297
+ * }
298
+ * // Span end event automatically sent to main thread
299
+ * ```
300
+ */
301
+ export function createWorkerLogger(
302
+ postMessage: PostMessageAnyFn,
303
+ namespace: string,
304
+ props: Record<string, unknown> = {},
305
+ options: WorkerLoggerOptions = {},
306
+ ): Logger {
307
+ const { parentSpanId = null, traceId = null } = options
308
+
309
+ function log(
310
+ level: "trace" | "debug" | "info" | "warn" | "error",
311
+ message: LazyMessage,
312
+ data?: Record<string, unknown>,
313
+ ): void {
314
+ const resolved = typeof message === "function" ? message() : message
315
+ try {
316
+ postMessage({
317
+ type: "log",
318
+ level,
319
+ namespace,
320
+ message: resolved,
321
+ data: data ? { ...props, ...data } : Object.keys(props).length > 0 ? props : undefined,
322
+ timestamp: Date.now(),
323
+ })
324
+ } catch (err) {
325
+ // postMessage failed (e.g. uncloneable data) — send a diagnostic fallback
326
+ try {
327
+ postMessage({
328
+ type: "log",
329
+ level: "error",
330
+ namespace,
331
+ message: `postMessage failed for ${level} "${resolved}": ${err instanceof Error ? err.message : String(err)}`,
332
+ timestamp: Date.now(),
333
+ })
334
+ } catch {
335
+ // Worker might be shutting down — truly nothing we can do
336
+ }
337
+ }
338
+ }
339
+
340
+ function createSpan(spanNamespace?: string, spanProps?: Record<string, unknown>): SpanLogger {
341
+ const fullNamespace = spanNamespace ? `${namespace}:${spanNamespace}` : namespace
342
+ const mergedProps = { ...props, ...spanProps }
343
+ const spanId = generateWorkerSpanId()
344
+ const spanTraceId = traceId || generateWorkerTraceId()
345
+ const startTime = Date.now()
346
+
347
+ // Mutable span data that can be set by the user
348
+ const customSpanData: Record<string, unknown> = {}
349
+
350
+ // Send span start event
351
+ try {
352
+ postMessage({
353
+ type: "span",
354
+ event: "start",
355
+ namespace: fullNamespace,
356
+ spanId,
357
+ traceId: spanTraceId,
358
+ parentId: parentSpanId,
359
+ startTime,
360
+ props: mergedProps,
361
+ spanData: {},
362
+ timestamp: Date.now(),
363
+ })
364
+ } catch (err) {
365
+ // postMessage failed (e.g. uncloneable props) — send a diagnostic fallback
366
+ try {
367
+ postMessage({
368
+ type: "log",
369
+ level: "error",
370
+ namespace: fullNamespace,
371
+ message: `postMessage failed for span start "${fullNamespace}": ${err instanceof Error ? err.message : String(err)}`,
372
+ timestamp: Date.now(),
373
+ })
374
+ } catch {
375
+ // Worker might be shutting down
376
+ }
377
+ }
378
+
379
+ let ended = false
380
+
381
+ const spanData: SpanData = createSpanDataProxy(
382
+ () => ({
383
+ id: spanId,
384
+ traceId: spanTraceId,
385
+ parentId: parentSpanId,
386
+ startTime,
387
+ endTime: ended ? Date.now() : null,
388
+ duration: Date.now() - startTime,
389
+ }),
390
+ customSpanData,
391
+ )
392
+
393
+ function endSpan(): void {
394
+ if (ended) return
395
+ ended = true
396
+
397
+ const endTime = Date.now()
398
+ const duration = endTime - startTime
399
+
400
+ try {
401
+ postMessage({
402
+ type: "span",
403
+ event: "end",
404
+ namespace: fullNamespace,
405
+ spanId,
406
+ traceId: spanTraceId,
407
+ parentId: parentSpanId,
408
+ startTime,
409
+ endTime,
410
+ duration,
411
+ props: mergedProps,
412
+ spanData: customSpanData,
413
+ timestamp: Date.now(),
414
+ })
415
+ } catch (err) {
416
+ // postMessage failed (e.g. uncloneable spanData) — send a diagnostic fallback
417
+ try {
418
+ postMessage({
419
+ type: "log",
420
+ level: "error",
421
+ namespace: fullNamespace,
422
+ message: `postMessage failed for span end "${fullNamespace}" (${duration}ms): ${err instanceof Error ? err.message : String(err)}`,
423
+ timestamp: Date.now(),
424
+ })
425
+ } catch {
426
+ // Worker might be shutting down
427
+ }
428
+ }
429
+ }
430
+
431
+ // Create child logger for the span
432
+ const childLogger = createWorkerLogger(postMessage, fullNamespace, mergedProps, {
433
+ parentSpanId: spanId,
434
+ traceId: spanTraceId,
435
+ })
436
+
437
+ const spanLogger: SpanLogger = {
438
+ ...childLogger,
439
+ spanData,
440
+ end: endSpan,
441
+ [Symbol.dispose]: endSpan,
442
+ }
443
+
444
+ return spanLogger
445
+ }
446
+
447
+ const logger: Logger = {
448
+ name: namespace,
449
+ props: Object.freeze({ ...props }),
450
+ spanData: null,
451
+
452
+ trace: (msg, data) => log("trace", msg, data),
453
+ debug: (msg, data) => log("debug", msg, data),
454
+ info: (msg, data) => log("info", msg, data),
455
+ warn: (msg, data) => log("warn", msg, data),
456
+ error: (msgOrError: LazyMessage | Error, data?: Record<string, unknown>) => {
457
+ if (msgOrError instanceof Error) {
458
+ log("error", msgOrError.message, {
459
+ ...data,
460
+ error_type: msgOrError.name,
461
+ error_stack: msgOrError.stack,
462
+ error_code: (msgOrError as NodeJS.ErrnoException).code,
463
+ })
464
+ } else {
465
+ log("error", msgOrError, data)
466
+ }
467
+ },
468
+
469
+ logger(childNamespace?: string, childProps?: Record<string, unknown>): Logger {
470
+ const fullNamespace = childNamespace ? `${namespace}:${childNamespace}` : namespace
471
+ return createWorkerLogger(postMessage, fullNamespace, { ...props, ...childProps }, options)
472
+ },
473
+
474
+ span: createSpan,
475
+
476
+ child(context: Record<string, unknown> | string): Logger {
477
+ if (typeof context === "string") {
478
+ return this.logger(context)
479
+ }
480
+ return createWorkerLogger(postMessage, namespace, { ...props, ...context }, options)
481
+ },
482
+
483
+ end(): void {
484
+ // No-op for non-span logger
485
+ },
486
+ }
487
+
488
+ return logger
489
+ }
490
+
491
+ // ============ Main Thread Side ============
492
+
493
+ export interface WorkerConsoleHandlerOptions {
494
+ /** Default namespace if message doesn't include one */
495
+ defaultNamespace?: string
496
+ /** Custom logger to use (defaults to creating one with the namespace) */
497
+ logger?: Logger
498
+ }
499
+
500
+ /** Safely stringify a value, handling circular refs and BigInt */
501
+ function safeStringify(value: unknown): string {
502
+ try {
503
+ return JSON.stringify(value)
504
+ } catch {
505
+ return String(value)
506
+ }
507
+ }
508
+
509
+ /** Format console args into a message string and optional data object */
510
+ function formatConsoleArgs(args: unknown[]): { message: string; data: Record<string, unknown> | undefined } {
511
+ const message =
512
+ args.length === 0
513
+ ? ""
514
+ : args.length === 1 && typeof args[0] === "string"
515
+ ? args[0]
516
+ : args.map((a) => (typeof a === "string" ? a : safeStringify(a))).join(" ")
517
+
518
+ const lastArg = args[args.length - 1]
519
+ const data =
520
+ args.length > 1 && typeof lastArg === "object" && lastArg !== null && !Array.isArray(lastArg)
521
+ ? (lastArg as Record<string, unknown>)
522
+ : undefined
523
+
524
+ return { message, data }
525
+ }
526
+
527
+ /** Dispatch a message to a logger at the given console level */
528
+ function dispatchToLogger(
529
+ logger: ConditionalLogger,
530
+ level: "log" | "debug" | "info" | "warn" | "error" | "trace",
531
+ message: string,
532
+ data?: Record<string, unknown>,
533
+ ): void {
534
+ switch (level) {
535
+ case "trace":
536
+ logger.trace?.(message, data)
537
+ break
538
+ case "debug":
539
+ logger.debug?.(message, data)
540
+ break
541
+ case "info":
542
+ case "log":
543
+ logger.info?.(message, data)
544
+ break
545
+ case "warn":
546
+ logger.warn?.(message, data)
547
+ break
548
+ case "error":
549
+ logger.error?.(message, data)
550
+ break
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Create a handler for worker console messages.
556
+ *
557
+ * Use this on the main thread to receive and output messages from workers.
558
+ *
559
+ * @param options - Handler options
560
+ * @returns Handler function to call with worker messages
561
+ *
562
+ * @example
563
+ * ```typescript
564
+ * import { createWorkerConsoleHandler } from "loggily/worker"
565
+ *
566
+ * const handleConsole = createWorkerConsoleHandler({
567
+ * defaultNamespace: "km:worker:parse"
568
+ * })
569
+ *
570
+ * worker.onmessage = (e) => {
571
+ * if (e.data.type === "console") {
572
+ * handleConsole(e.data)
573
+ * } else {
574
+ * // Handle other message types
575
+ * }
576
+ * }
577
+ * ```
578
+ */
579
+ export function createWorkerConsoleHandler(
580
+ options: WorkerConsoleHandlerOptions = {},
581
+ ): (message: WorkerConsoleMessage) => void {
582
+ const loggers = new Map<string, ConditionalLogger>()
583
+
584
+ function getLogger(namespace?: string): ConditionalLogger {
585
+ const ns = namespace || options.defaultNamespace || "worker"
586
+
587
+ let logger = loggers.get(ns)
588
+ if (!logger) {
589
+ logger = options.logger ? (options.logger as ConditionalLogger) : createLogger(ns)
590
+ loggers.set(ns, logger)
591
+ }
592
+ return logger
593
+ }
594
+
595
+ return (message: WorkerConsoleMessage) => {
596
+ const logger = getLogger(message.namespace)
597
+ const { message: msg, data } = formatConsoleArgs(message.args)
598
+ dispatchToLogger(logger, message.level, msg, data)
599
+ }
600
+ }
601
+
602
+ // ============ Full Logger Handler ============
603
+
604
+ export interface WorkerLogHandlerOptions {
605
+ /** Enable span output (default: uses spansAreEnabled()) */
606
+ enableSpans?: boolean
607
+ }
608
+
609
+ /**
610
+ * Create a handler for worker logger messages (logs and spans).
611
+ *
612
+ * Use this on the main thread to receive and output messages from workers
613
+ * that use createWorkerLogger.
614
+ *
615
+ * @param options - Handler options
616
+ * @returns Handler function to call with worker messages
617
+ *
618
+ * @example
619
+ * ```typescript
620
+ * import { createWorkerLogHandler, isWorkerMessage } from "loggily/worker"
621
+ *
622
+ * const handleLog = createWorkerLogHandler()
623
+ *
624
+ * worker.onmessage = (e) => {
625
+ * if (isWorkerMessage(e.data)) {
626
+ * handleLog(e.data)
627
+ * } else {
628
+ * // Handle other message types
629
+ * }
630
+ * }
631
+ * ```
632
+ */
633
+ export function createWorkerLogHandler(options: WorkerLogHandlerOptions = {}): (message: WorkerMessage) => void {
634
+ const loggers = new Map<string, ConditionalLogger>()
635
+
636
+ // Enable spans if requested
637
+ if (options.enableSpans) {
638
+ enableSpans()
639
+ }
640
+
641
+ function getLogger(namespace: string): ConditionalLogger {
642
+ let logger = loggers.get(namespace)
643
+ if (!logger) {
644
+ logger = createLogger(namespace)
645
+ loggers.set(namespace, logger)
646
+ }
647
+ return logger
648
+ }
649
+
650
+ return (message: WorkerMessage) => {
651
+ if (isWorkerConsoleMessage(message)) {
652
+ const logger = getLogger(message.namespace || "worker")
653
+ const { message: msg, data } = formatConsoleArgs(message.args)
654
+ dispatchToLogger(logger, message.level, msg, data)
655
+ } else if (isWorkerLogMessage(message)) {
656
+ const logger = getLogger(message.namespace)
657
+ dispatchToLogger(logger, message.level, message.message, message.data)
658
+ } else if (isWorkerSpanMessage(message)) {
659
+ // Handle span events
660
+ // For span end events, output the span with original worker timing data
661
+ if (message.event === "end") {
662
+ writeSpan(message.namespace, message.duration ?? 0, {
663
+ span_id: message.spanId,
664
+ trace_id: message.traceId,
665
+ parent_id: message.parentId,
666
+ ...message.props,
667
+ ...message.spanData,
668
+ })
669
+ }
670
+ // Start events are informational only on main thread
671
+ // (the actual timing happens in the worker)
672
+ }
673
+ }
674
+ }