@logtape/otel 1.3.5 → 1.4.0-dev.408

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.
@@ -0,0 +1,801 @@
1
+ // TODO: Add substantial tests for OpenTelemetry integration.
2
+ // Current tests only verify basic browser compatibility and that the sink
3
+ // can be created without errors. Future tests should include:
4
+ // - Actual log record processing and OpenTelemetry output verification
5
+ // - Integration with real OpenTelemetry collectors
6
+ // - Message formatting and attribute handling
7
+ // - Error handling scenarios
8
+ // - Performance testing
9
+
10
+ import { suite } from "@alinea/suite";
11
+ import { assertEquals, assertExists } from "@std/assert";
12
+ import type { LogRecord } from "@logtape/logtape";
13
+ import {
14
+ getOpenTelemetrySink,
15
+ type OpenTelemetrySink,
16
+ type OpenTelemetrySinkExporterOptions,
17
+ type OpenTelemetrySinkProviderOptions,
18
+ } from "./mod.ts";
19
+
20
+ const test = suite(import.meta);
21
+
22
+ // Helper to create a mock log record
23
+ function createMockLogRecord(overrides: Partial<LogRecord> = {}): LogRecord {
24
+ return {
25
+ category: ["test", "category"],
26
+ level: "info",
27
+ message: ["Hello, ", { name: "world" }, "!"],
28
+ rawMessage: "Hello, {name}!",
29
+ timestamp: Date.now(),
30
+ properties: {},
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ // Mock logger that captures emitted records
36
+ interface MockLogRecord {
37
+ severityNumber: number;
38
+ severityText: string;
39
+ body: unknown;
40
+ attributes: Record<string, unknown>;
41
+ timestamp: Date;
42
+ }
43
+
44
+ function createMockLoggerProvider() {
45
+ const emittedRecords: MockLogRecord[] = [];
46
+ let shutdownCalled = false;
47
+
48
+ const mockLogger = {
49
+ emit: (record: MockLogRecord) => {
50
+ emittedRecords.push(record);
51
+ },
52
+ };
53
+
54
+ const mockLoggerProvider = {
55
+ getLogger: (_name: string, _version?: string) => mockLogger,
56
+ shutdown: () => {
57
+ shutdownCalled = true;
58
+ return Promise.resolve();
59
+ },
60
+ };
61
+
62
+ return {
63
+ provider: mockLoggerProvider,
64
+ emittedRecords,
65
+ isShutdownCalled: () => shutdownCalled,
66
+ };
67
+ }
68
+
69
+ // =============================================================================
70
+ // Basic sink creation tests
71
+ // =============================================================================
72
+
73
+ test("getOpenTelemetrySink() creates sink without node:process dependency", () => {
74
+ // This test should pass in all environments (Deno, Node.js, browsers)
75
+ // without throwing errors about missing node:process
76
+ const sink = getOpenTelemetrySink();
77
+
78
+ assertEquals(typeof sink, "function");
79
+ });
80
+
81
+ test("getOpenTelemetrySink() works with explicit serviceName", () => {
82
+ const sink = getOpenTelemetrySink({
83
+ serviceName: "test-service",
84
+ });
85
+
86
+ assertEquals(typeof sink, "function");
87
+ });
88
+
89
+ test("getOpenTelemetrySink() handles missing environment variables gracefully", () => {
90
+ // Should not throw even if OTEL_SERVICE_NAME is not set
91
+ const sink = getOpenTelemetrySink({
92
+ // serviceName not provided, should fall back to env var
93
+ });
94
+
95
+ assertEquals(typeof sink, "function");
96
+ });
97
+
98
+ test("getOpenTelemetrySink() with diagnostics enabled", () => {
99
+ const sink = getOpenTelemetrySink({
100
+ diagnostics: true,
101
+ });
102
+
103
+ assertEquals(typeof sink, "function");
104
+ });
105
+
106
+ test("getOpenTelemetrySink() with custom messageType", () => {
107
+ const sink = getOpenTelemetrySink({
108
+ messageType: "array",
109
+ });
110
+
111
+ assertEquals(typeof sink, "function");
112
+ });
113
+
114
+ test("getOpenTelemetrySink() with custom objectRenderer", () => {
115
+ const sink = getOpenTelemetrySink({
116
+ objectRenderer: "json",
117
+ });
118
+
119
+ assertEquals(typeof sink, "function");
120
+ });
121
+
122
+ test("getOpenTelemetrySink() with custom bodyFormatter", () => {
123
+ const sink = getOpenTelemetrySink({
124
+ messageType: (message) => message.join(" "),
125
+ });
126
+
127
+ assertEquals(typeof sink, "function");
128
+ });
129
+
130
+ test("getOpenTelemetrySink() with custom loggerProvider", () => {
131
+ const { provider } = createMockLoggerProvider();
132
+
133
+ const options: OpenTelemetrySinkProviderOptions = {
134
+ loggerProvider: provider as never,
135
+ };
136
+ const sink = getOpenTelemetrySink(options);
137
+
138
+ assertEquals(typeof sink, "function");
139
+ assertEquals(typeof sink[Symbol.asyncDispose], "function");
140
+ });
141
+
142
+ test("getOpenTelemetrySink() exporter options type check", () => {
143
+ // Verify that exporter options work correctly
144
+ const options: OpenTelemetrySinkExporterOptions = {
145
+ serviceName: "test-service",
146
+ otlpExporterConfig: {
147
+ url: "http://localhost:4318/v1/logs",
148
+ },
149
+ };
150
+ const sink = getOpenTelemetrySink(options);
151
+
152
+ assertEquals(typeof sink, "function");
153
+ // Lazy initialization means async dispose should be available
154
+ assertEquals(typeof sink[Symbol.asyncDispose], "function");
155
+ });
156
+
157
+ test("getOpenTelemetrySink() sink has async dispose", () => {
158
+ const sink = getOpenTelemetrySink();
159
+
160
+ // All sinks should have async dispose for proper cleanup
161
+ assertEquals(typeof sink[Symbol.asyncDispose], "function");
162
+ });
163
+
164
+ // =============================================================================
165
+ // Log record processing tests with mock logger provider
166
+ // =============================================================================
167
+
168
+ test("sink emits log records to the logger provider", () => {
169
+ const { provider, emittedRecords } = createMockLoggerProvider();
170
+ const sink = getOpenTelemetrySink({
171
+ loggerProvider: provider as never,
172
+ });
173
+
174
+ const record = createMockLogRecord();
175
+ sink(record);
176
+
177
+ assertEquals(emittedRecords.length, 1);
178
+ });
179
+
180
+ test("sink correctly maps log levels to severity numbers", () => {
181
+ const { provider, emittedRecords } = createMockLoggerProvider();
182
+ const sink = getOpenTelemetrySink({
183
+ loggerProvider: provider as never,
184
+ });
185
+
186
+ const levels = [
187
+ "trace",
188
+ "debug",
189
+ "info",
190
+ "warning",
191
+ "error",
192
+ "fatal",
193
+ ] as const;
194
+ const expectedSeverities = [1, 5, 9, 13, 17, 21]; // TRACE=1, DEBUG=5, INFO=9, WARN=13, ERROR=17, FATAL=21
195
+
196
+ for (let i = 0; i < levels.length; i++) {
197
+ const record = createMockLogRecord({ level: levels[i] });
198
+ sink(record);
199
+ }
200
+
201
+ assertEquals(emittedRecords.length, levels.length);
202
+ for (let i = 0; i < levels.length; i++) {
203
+ assertEquals(emittedRecords[i].severityNumber, expectedSeverities[i]);
204
+ assertEquals(emittedRecords[i].severityText, levels[i]);
205
+ }
206
+ });
207
+
208
+ test("sink converts message to string by default", () => {
209
+ const { provider, emittedRecords } = createMockLoggerProvider();
210
+ const sink = getOpenTelemetrySink({
211
+ loggerProvider: provider as never,
212
+ messageType: "string",
213
+ objectRenderer: "json",
214
+ });
215
+
216
+ const record = createMockLogRecord({
217
+ message: ["Hello, ", "world", "!"],
218
+ });
219
+ sink(record);
220
+
221
+ assertEquals(emittedRecords.length, 1);
222
+ assertEquals(emittedRecords[0].body, "Hello, world!");
223
+ });
224
+
225
+ test("sink converts message to array when messageType is 'array'", () => {
226
+ const { provider, emittedRecords } = createMockLoggerProvider();
227
+ const sink = getOpenTelemetrySink({
228
+ loggerProvider: provider as never,
229
+ messageType: "array",
230
+ objectRenderer: "json",
231
+ });
232
+
233
+ const record = createMockLogRecord({
234
+ message: ["Hello, ", "world", "!"],
235
+ });
236
+ sink(record);
237
+
238
+ assertEquals(emittedRecords.length, 1);
239
+ assertEquals(emittedRecords[0].body, ["Hello, ", "world", "!"]);
240
+ });
241
+
242
+ test("sink uses custom bodyFormatter when provided", () => {
243
+ const { provider, emittedRecords } = createMockLoggerProvider();
244
+ const sink = getOpenTelemetrySink({
245
+ loggerProvider: provider as never,
246
+ messageType: (message) => `CUSTOM: ${message.filter(Boolean).join("")}`,
247
+ objectRenderer: "json",
248
+ });
249
+
250
+ const record = createMockLogRecord({
251
+ message: ["Hello, ", "world", "!"],
252
+ });
253
+ sink(record);
254
+
255
+ assertEquals(emittedRecords.length, 1);
256
+ assertEquals(emittedRecords[0].body, "CUSTOM: Hello, world!");
257
+ });
258
+
259
+ test("sink includes category in attributes", () => {
260
+ const { provider, emittedRecords } = createMockLoggerProvider();
261
+ const sink = getOpenTelemetrySink({
262
+ loggerProvider: provider as never,
263
+ });
264
+
265
+ const record = createMockLogRecord({
266
+ category: ["app", "module", "component"],
267
+ });
268
+ sink(record);
269
+
270
+ assertEquals(emittedRecords.length, 1);
271
+ assertEquals(
272
+ emittedRecords[0].attributes["category"],
273
+ ["app", "module", "component"],
274
+ );
275
+ });
276
+
277
+ test("sink converts properties to attributes", () => {
278
+ const { provider, emittedRecords } = createMockLoggerProvider();
279
+ const sink = getOpenTelemetrySink({
280
+ loggerProvider: provider as never,
281
+ objectRenderer: "json",
282
+ });
283
+
284
+ const record = createMockLogRecord({
285
+ properties: {
286
+ userId: 123,
287
+ action: "login",
288
+ details: { ip: "127.0.0.1" },
289
+ },
290
+ });
291
+ sink(record);
292
+
293
+ assertEquals(emittedRecords.length, 1);
294
+ assertEquals(emittedRecords[0].attributes["attributes.userId"], "123");
295
+ assertEquals(emittedRecords[0].attributes["attributes.action"], "login");
296
+ assertEquals(
297
+ emittedRecords[0].attributes["attributes.details"],
298
+ '{"ip":"127.0.0.1"}',
299
+ );
300
+ });
301
+
302
+ test("sink correctly converts timestamp", () => {
303
+ const { provider, emittedRecords } = createMockLoggerProvider();
304
+ const sink = getOpenTelemetrySink({
305
+ loggerProvider: provider as never,
306
+ });
307
+
308
+ const timestamp = 1700000000000;
309
+ const record = createMockLogRecord({ timestamp });
310
+ sink(record);
311
+
312
+ assertEquals(emittedRecords.length, 1);
313
+ assertExists(emittedRecords[0].timestamp);
314
+ assertEquals(emittedRecords[0].timestamp.getTime(), timestamp);
315
+ });
316
+
317
+ // =============================================================================
318
+ // Meta logger filtering tests
319
+ // =============================================================================
320
+
321
+ test("sink ignores logs from logtape.meta.otel category", () => {
322
+ const { provider, emittedRecords } = createMockLoggerProvider();
323
+ const sink = getOpenTelemetrySink({
324
+ loggerProvider: provider as never,
325
+ });
326
+
327
+ // This should be ignored
328
+ const metaRecord = createMockLogRecord({
329
+ category: ["logtape", "meta", "otel"],
330
+ message: ["Meta log message"],
331
+ });
332
+ sink(metaRecord);
333
+
334
+ // This should be emitted
335
+ const normalRecord = createMockLogRecord({
336
+ category: ["app", "module"],
337
+ message: ["Normal log message"],
338
+ });
339
+ sink(normalRecord);
340
+
341
+ assertEquals(emittedRecords.length, 1);
342
+ assertEquals(emittedRecords[0].attributes["category"], ["app", "module"]);
343
+ });
344
+
345
+ test("sink does not ignore partial matches of meta category", () => {
346
+ const { provider, emittedRecords } = createMockLoggerProvider();
347
+ const sink = getOpenTelemetrySink({
348
+ loggerProvider: provider as never,
349
+ });
350
+
351
+ // These should NOT be ignored (partial matches or different third element)
352
+ sink(createMockLogRecord({ category: ["logtape"] }));
353
+ sink(createMockLogRecord({ category: ["logtape", "meta"] }));
354
+ sink(createMockLogRecord({ category: ["logtape", "meta", "other"] }));
355
+
356
+ assertEquals(emittedRecords.length, 3);
357
+ });
358
+
359
+ test("sink ignores logs from logtape.meta.otel with children", () => {
360
+ const { provider, emittedRecords } = createMockLoggerProvider();
361
+ const sink = getOpenTelemetrySink({
362
+ loggerProvider: provider as never,
363
+ });
364
+
365
+ // Child categories of logtape.meta.otel are also ignored
366
+ // because the filter checks category[0], [1], [2] only
367
+ sink(
368
+ createMockLogRecord({ category: ["logtape", "meta", "otel", "child"] }),
369
+ );
370
+
371
+ assertEquals(emittedRecords.length, 0);
372
+ });
373
+
374
+ // =============================================================================
375
+ // Async dispose tests
376
+ // =============================================================================
377
+
378
+ test("async dispose calls shutdown on logger provider", async () => {
379
+ const { provider, isShutdownCalled } = createMockLoggerProvider();
380
+ const sink = getOpenTelemetrySink({
381
+ loggerProvider: provider as never,
382
+ });
383
+
384
+ assertEquals(isShutdownCalled(), false);
385
+
386
+ await sink[Symbol.asyncDispose]();
387
+
388
+ assertEquals(isShutdownCalled(), true);
389
+ });
390
+
391
+ test("async dispose handles provider without shutdown method", async () => {
392
+ const providerWithoutShutdown = {
393
+ getLogger: () => ({
394
+ emit: () => {},
395
+ }),
396
+ // No shutdown method
397
+ };
398
+
399
+ const sink = getOpenTelemetrySink({
400
+ loggerProvider: providerWithoutShutdown as never,
401
+ });
402
+
403
+ // Should not throw
404
+ await sink[Symbol.asyncDispose]();
405
+ });
406
+
407
+ // =============================================================================
408
+ // Edge cases and error handling
409
+ // =============================================================================
410
+
411
+ test("sink handles null/undefined values in properties", () => {
412
+ const { provider, emittedRecords } = createMockLoggerProvider();
413
+ const sink = getOpenTelemetrySink({
414
+ loggerProvider: provider as never,
415
+ objectRenderer: "json",
416
+ });
417
+
418
+ const record = createMockLogRecord({
419
+ properties: {
420
+ nullValue: null,
421
+ undefinedValue: undefined,
422
+ validValue: "test",
423
+ },
424
+ });
425
+ sink(record);
426
+
427
+ assertEquals(emittedRecords.length, 1);
428
+ // null and undefined should be skipped
429
+ assertEquals(
430
+ emittedRecords[0].attributes["attributes.nullValue"],
431
+ undefined,
432
+ );
433
+ assertEquals(
434
+ emittedRecords[0].attributes["attributes.undefinedValue"],
435
+ undefined,
436
+ );
437
+ assertEquals(emittedRecords[0].attributes["attributes.validValue"], "test");
438
+ });
439
+
440
+ test("sink handles array values in properties", () => {
441
+ const { provider, emittedRecords } = createMockLoggerProvider();
442
+ const sink = getOpenTelemetrySink({
443
+ loggerProvider: provider as never,
444
+ objectRenderer: "json",
445
+ });
446
+
447
+ const record = createMockLogRecord({
448
+ properties: {
449
+ tags: ["a", "b", "c"],
450
+ mixedArray: [1, "two", 3],
451
+ },
452
+ });
453
+ sink(record);
454
+
455
+ assertEquals(emittedRecords.length, 1);
456
+ assertEquals(
457
+ emittedRecords[0].attributes["attributes.tags"],
458
+ ["a", "b", "c"],
459
+ );
460
+ // Mixed arrays: implementation converts to strings when types differ
461
+ // but the actual behavior is that it keeps original values after detecting mixed types
462
+ assertEquals(emittedRecords[0].attributes["attributes.mixedArray"], [
463
+ 1,
464
+ "two",
465
+ 3,
466
+ ]);
467
+ });
468
+
469
+ test("sink handles Date objects in properties", () => {
470
+ const { provider, emittedRecords } = createMockLoggerProvider();
471
+ const sink = getOpenTelemetrySink({
472
+ loggerProvider: provider as never,
473
+ objectRenderer: "json",
474
+ });
475
+
476
+ const testDate = new Date("2024-01-15T10:30:00.000Z");
477
+ const record = createMockLogRecord({
478
+ properties: {
479
+ timestamp: testDate,
480
+ },
481
+ });
482
+ sink(record);
483
+
484
+ assertEquals(emittedRecords.length, 1);
485
+ assertEquals(
486
+ emittedRecords[0].attributes["attributes.timestamp"],
487
+ "2024-01-15T10:30:00.000Z",
488
+ );
489
+ });
490
+
491
+ test("sink handles empty message array", () => {
492
+ const { provider, emittedRecords } = createMockLoggerProvider();
493
+ const sink = getOpenTelemetrySink({
494
+ loggerProvider: provider as never,
495
+ messageType: "string",
496
+ });
497
+
498
+ const record = createMockLogRecord({
499
+ message: [],
500
+ });
501
+ sink(record);
502
+
503
+ assertEquals(emittedRecords.length, 1);
504
+ assertEquals(emittedRecords[0].body, "");
505
+ });
506
+
507
+ test("sink handles empty properties object", () => {
508
+ const { provider, emittedRecords } = createMockLoggerProvider();
509
+ const sink = getOpenTelemetrySink({
510
+ loggerProvider: provider as never,
511
+ });
512
+
513
+ const record = createMockLogRecord({
514
+ properties: {},
515
+ });
516
+ sink(record);
517
+
518
+ assertEquals(emittedRecords.length, 1);
519
+ // Only category should be in attributes
520
+ assertEquals(Object.keys(emittedRecords[0].attributes).length, 1);
521
+ assertExists(emittedRecords[0].attributes["category"]);
522
+ });
523
+
524
+ test("sink handles unknown log level", () => {
525
+ const { provider, emittedRecords } = createMockLoggerProvider();
526
+ const sink = getOpenTelemetrySink({
527
+ loggerProvider: provider as never,
528
+ });
529
+
530
+ // Use type assertion to test runtime behavior with an unknown level
531
+ const record = createMockLogRecord({
532
+ level: "custom_level" as LogRecord["level"],
533
+ });
534
+ sink(record);
535
+
536
+ assertEquals(emittedRecords.length, 1);
537
+ assertEquals(emittedRecords[0].severityNumber, 0); // UNSPECIFIED
538
+ assertEquals(emittedRecords[0].severityText, "custom_level");
539
+ });
540
+
541
+ // =============================================================================
542
+ // Lazy initialization tests (exporter options path)
543
+ // =============================================================================
544
+
545
+ test("lazy init sink can be disposed before any logs", async () => {
546
+ const sink = getOpenTelemetrySink({
547
+ serviceName: "test-service",
548
+ });
549
+
550
+ // Should not throw when disposing before any logs
551
+ await sink[Symbol.asyncDispose]();
552
+ });
553
+
554
+ test("lazy init sink creates function with correct signature", () => {
555
+ const sink = getOpenTelemetrySink({
556
+ serviceName: "test-service",
557
+ });
558
+
559
+ assertEquals(typeof sink, "function");
560
+ assertEquals(sink.length, 1); // Expects one argument (LogRecord)
561
+ assertEquals(typeof sink[Symbol.asyncDispose], "function");
562
+ });
563
+
564
+ // =============================================================================
565
+ // Object renderer tests
566
+ // =============================================================================
567
+
568
+ test("objectRenderer 'json' uses JSON.stringify for objects", () => {
569
+ const { provider, emittedRecords } = createMockLoggerProvider();
570
+ const sink = getOpenTelemetrySink({
571
+ loggerProvider: provider as never,
572
+ objectRenderer: "json",
573
+ messageType: "string",
574
+ });
575
+
576
+ const record = createMockLogRecord({
577
+ message: ["Data: ", { foo: "bar" }, ""],
578
+ });
579
+ sink(record);
580
+
581
+ assertEquals(emittedRecords.length, 1);
582
+ assertEquals(emittedRecords[0].body, 'Data: {"foo":"bar"}');
583
+ });
584
+
585
+ test("objectRenderer 'inspect' uses platform inspect function", () => {
586
+ const { provider, emittedRecords } = createMockLoggerProvider();
587
+ const sink = getOpenTelemetrySink({
588
+ loggerProvider: provider as never,
589
+ objectRenderer: "inspect",
590
+ messageType: "string",
591
+ });
592
+
593
+ const record = createMockLogRecord({
594
+ message: ["Data: ", { foo: "bar" }, ""],
595
+ });
596
+ sink(record);
597
+
598
+ assertEquals(emittedRecords.length, 1);
599
+ // The exact output depends on the runtime's inspect function
600
+ // but it should contain the object representation
601
+ const body = emittedRecords[0].body as string;
602
+ assertEquals(body.includes("foo"), true);
603
+ assertEquals(body.includes("bar"), true);
604
+ });
605
+
606
+ // =============================================================================
607
+ // Multiple log records processing
608
+ // =============================================================================
609
+
610
+ test("sink processes multiple log records in order", () => {
611
+ const { provider, emittedRecords } = createMockLoggerProvider();
612
+ const sink = getOpenTelemetrySink({
613
+ loggerProvider: provider as never,
614
+ messageType: "string",
615
+ });
616
+
617
+ for (let i = 0; i < 5; i++) {
618
+ sink(createMockLogRecord({
619
+ message: [`Message ${i}`],
620
+ }));
621
+ }
622
+
623
+ assertEquals(emittedRecords.length, 5);
624
+ for (let i = 0; i < 5; i++) {
625
+ assertEquals(emittedRecords[i].body, `Message ${i}`);
626
+ }
627
+ });
628
+
629
+ test("sink handles rapid succession of logs", () => {
630
+ const { provider, emittedRecords } = createMockLoggerProvider();
631
+ const sink = getOpenTelemetrySink({
632
+ loggerProvider: provider as never,
633
+ });
634
+
635
+ const count = 100;
636
+ for (let i = 0; i < count; i++) {
637
+ sink(createMockLogRecord({ timestamp: Date.now() + i }));
638
+ }
639
+
640
+ assertEquals(emittedRecords.length, count);
641
+ });
642
+
643
+ // =============================================================================
644
+ // NoopLogger fallback tests (when no endpoint is configured)
645
+ // =============================================================================
646
+
647
+ test("sink with no endpoint config creates valid sink function", () => {
648
+ // When no endpoint is configured (no env vars, no url in config),
649
+ // the sink should still work without throwing errors
650
+ const sink = getOpenTelemetrySink({
651
+ serviceName: "test-service",
652
+ // No otlpExporterConfig.url and no OTEL_EXPORTER_OTLP_ENDPOINT env var
653
+ });
654
+
655
+ assertEquals(typeof sink, "function");
656
+ assertEquals(typeof sink[Symbol.asyncDispose], "function");
657
+ });
658
+
659
+ test("sink with no endpoint accepts logs without errors", () => {
660
+ const sink = getOpenTelemetrySink({
661
+ serviceName: "test-service",
662
+ });
663
+
664
+ // Should not throw when logging without an endpoint
665
+ const record = createMockLogRecord();
666
+ sink(record);
667
+ sink(record);
668
+ sink(record);
669
+
670
+ // No assertion needed - we're just verifying it doesn't throw
671
+ });
672
+
673
+ test("sink with explicit url in config should not use noop", () => {
674
+ // When a URL is explicitly provided, it should attempt to use a real exporter
675
+ const sink = getOpenTelemetrySink({
676
+ serviceName: "test-service",
677
+ otlpExporterConfig: {
678
+ url: "http://localhost:4318/v1/logs",
679
+ },
680
+ });
681
+
682
+ assertEquals(typeof sink, "function");
683
+ });
684
+
685
+ test("sink with no endpoint can be disposed cleanly", async () => {
686
+ const sink = getOpenTelemetrySink({
687
+ serviceName: "test-service",
688
+ });
689
+
690
+ // Log something to trigger lazy initialization
691
+ sink(createMockLogRecord());
692
+
693
+ // Should not throw when disposing
694
+ await sink[Symbol.asyncDispose]();
695
+ });
696
+
697
+ // =============================================================================
698
+ // ready property tests (added in 1.3.1)
699
+ // =============================================================================
700
+
701
+ test("sink has ready property that is a Promise", () => {
702
+ const sink = getOpenTelemetrySink({
703
+ serviceName: "test-service",
704
+ });
705
+
706
+ assertExists(sink.ready);
707
+ assertEquals(sink.ready instanceof Promise, true);
708
+ });
709
+
710
+ test("sink with loggerProvider has ready that resolves immediately", async () => {
711
+ const { provider } = createMockLoggerProvider();
712
+ const sink = getOpenTelemetrySink({
713
+ loggerProvider: provider as never,
714
+ });
715
+
716
+ // Should resolve immediately without waiting
717
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
718
+ const resolved = await Promise.race([
719
+ sink.ready.then(() => "resolved"),
720
+ new Promise((resolve) => {
721
+ timeoutId = setTimeout(() => resolve("timeout"), 10);
722
+ }),
723
+ ]);
724
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
725
+
726
+ assertEquals(resolved, "resolved");
727
+ });
728
+
729
+ test("lazy init sink ready resolves after initialization", async () => {
730
+ const sink = getOpenTelemetrySink({
731
+ serviceName: "test-service",
732
+ });
733
+
734
+ // Send a log to trigger initialization
735
+ sink(createMockLogRecord());
736
+
737
+ // ready should eventually resolve
738
+ await sink.ready;
739
+
740
+ // Should not throw
741
+ await sink[Symbol.asyncDispose]();
742
+ });
743
+
744
+ // =============================================================================
745
+ // Regression test for issue #110: logs during lazy initialization were dropped
746
+ // https://github.com/dahlia/logtape/issues/110
747
+ // =============================================================================
748
+
749
+ test("issue #110: multiple logs during sync initialization are all emitted", () => {
750
+ const { provider, emittedRecords } = createMockLoggerProvider();
751
+ const sink = getOpenTelemetrySink({
752
+ loggerProvider: provider as never,
753
+ });
754
+
755
+ // Send multiple logs rapidly (simulating what happens after configure())
756
+ sink(createMockLogRecord({ message: ["Log 1"] }));
757
+ sink(createMockLogRecord({ message: ["Log 2"] }));
758
+ sink(createMockLogRecord({ message: ["Log 3"] }));
759
+ sink(createMockLogRecord({ message: ["Log 4"] }));
760
+ sink(createMockLogRecord({ message: ["Log 5"] }));
761
+
762
+ // All logs should be emitted (this worked before, but verifies the fix
763
+ // doesn't break synchronous path)
764
+ assertEquals(emittedRecords.length, 5);
765
+ });
766
+
767
+ test("issue #110: sink buffers logs during lazy initialization", async () => {
768
+ // This test verifies that logs sent during lazy initialization are buffered
769
+ // and emitted once initialization completes.
770
+ // Note: We can't directly test the lazy init path with a mock provider,
771
+ // but we can verify the sink accepts multiple logs and completes without error.
772
+ const sink = getOpenTelemetrySink({
773
+ serviceName: "test-service",
774
+ // No endpoint configured, so noop logger will be used
775
+ });
776
+
777
+ // Send multiple logs rapidly before initialization completes
778
+ for (let i = 0; i < 10; i++) {
779
+ sink(createMockLogRecord({ message: [`Log ${i}`] }));
780
+ }
781
+
782
+ // Wait for initialization to complete
783
+ await sink.ready;
784
+
785
+ // The sink should have processed all logs without errors
786
+ // (with noop logger they won't actually be sent anywhere,
787
+ // but they shouldn't be dropped either)
788
+
789
+ await sink[Symbol.asyncDispose]();
790
+ });
791
+
792
+ test("OpenTelemetrySink type has ready property", () => {
793
+ // Type check: verify OpenTelemetrySink interface includes ready
794
+ const sink: OpenTelemetrySink = getOpenTelemetrySink({
795
+ serviceName: "test-service",
796
+ });
797
+
798
+ // TypeScript should allow accessing ready property
799
+ const _ready: Promise<void> = sink.ready;
800
+ assertExists(_ready);
801
+ });