@logtape/otel 1.3.5 → 1.4.0-dev.409
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/deno.json +12 -0
- package/dist/deno.cjs +1 -1
- package/dist/deno.js +1 -1
- package/dist/deno.js.map +1 -1
- package/package.json +2 -5
- package/sample.ts +27 -0
- package/src/mod.test.ts +801 -0
- package/src/mod.ts +696 -0
- package/tsdown.config.ts +11 -0
package/src/mod.test.ts
ADDED
|
@@ -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
|
+
});
|