@typokit/otel 0.1.4
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/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/log-bridge.d.ts +22 -0
- package/dist/log-bridge.d.ts.map +1 -0
- package/dist/log-bridge.js +128 -0
- package/dist/log-bridge.js.map +1 -0
- package/dist/logger.d.ts +28 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +100 -0
- package/dist/logger.js.map +1 -0
- package/dist/metrics.d.ts +62 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +260 -0
- package/dist/metrics.js.map +1 -0
- package/dist/redact.d.ts +7 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +46 -0
- package/dist/redact.js.map +1 -0
- package/dist/tracing.d.ts +94 -0
- package/dist/tracing.d.ts.map +1 -0
- package/dist/tracing.js +292 -0
- package/dist/tracing.js.map +1 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/package.json +28 -0
- package/src/index.test.ts +310 -0
- package/src/index.ts +45 -0
- package/src/log-bridge.test.ts +511 -0
- package/src/log-bridge.ts +150 -0
- package/src/logger.ts +143 -0
- package/src/metrics.test.ts +373 -0
- package/src/metrics.ts +337 -0
- package/src/redact.ts +52 -0
- package/src/tracing.test.ts +425 -0
- package/src/tracing.ts +377 -0
- package/src/types.ts +145 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { describe, it, expect } from "@rstest/core";
|
|
2
|
+
import { OtelLogSink, createOtelLogSink } from "./log-bridge.js";
|
|
3
|
+
import { StructuredLogger } from "./logger.js";
|
|
4
|
+
import type { LogEntry, LogSink } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** Test sink that captures log entries */
|
|
7
|
+
class TestSink implements LogSink {
|
|
8
|
+
entries: LogEntry[] = [];
|
|
9
|
+
write(entry: LogEntry): void {
|
|
10
|
+
this.entries.push(entry);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("OtelLogSink", () => {
|
|
15
|
+
it("should be constructable with default options", () => {
|
|
16
|
+
const sink = new OtelLogSink();
|
|
17
|
+
expect(sink).toBeDefined();
|
|
18
|
+
expect(typeof sink.write).toBe("function");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should be constructable with custom endpoint and serviceName", () => {
|
|
22
|
+
const sink = new OtelLogSink({
|
|
23
|
+
endpoint: "http://collector:4318/v1/logs",
|
|
24
|
+
serviceName: "my-service",
|
|
25
|
+
});
|
|
26
|
+
expect(sink).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should forward log entries to OTel collector with correct OTLP format", () => {
|
|
30
|
+
const fetchCalls: Array<{ url: string; init: Record<string, unknown> }> =
|
|
31
|
+
[];
|
|
32
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
33
|
+
"fetch"
|
|
34
|
+
];
|
|
35
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] = (
|
|
36
|
+
url: string,
|
|
37
|
+
init: Record<string, unknown>,
|
|
38
|
+
) => {
|
|
39
|
+
fetchCalls.push({ url, init });
|
|
40
|
+
return Promise.resolve({ ok: true });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const sink = new OtelLogSink({
|
|
45
|
+
endpoint: "http://test:4318/v1/logs",
|
|
46
|
+
serviceName: "test-svc",
|
|
47
|
+
});
|
|
48
|
+
const entry: LogEntry = {
|
|
49
|
+
level: "info",
|
|
50
|
+
message: "test log message",
|
|
51
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
52
|
+
route: "GET /users",
|
|
53
|
+
phase: "handler",
|
|
54
|
+
requestId: "req-123",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
sink.write(entry);
|
|
58
|
+
|
|
59
|
+
expect(fetchCalls).toHaveLength(1);
|
|
60
|
+
expect(fetchCalls[0].url).toBe("http://test:4318/v1/logs");
|
|
61
|
+
|
|
62
|
+
const body = JSON.parse(fetchCalls[0].init["body"] as string);
|
|
63
|
+
const resourceLogs = body.resourceLogs;
|
|
64
|
+
expect(resourceLogs).toHaveLength(1);
|
|
65
|
+
|
|
66
|
+
// Check resource service.name
|
|
67
|
+
const resource = resourceLogs[0].resource;
|
|
68
|
+
expect(resource.attributes[0].key).toBe("service.name");
|
|
69
|
+
expect(resource.attributes[0].value.stringValue).toBe("test-svc");
|
|
70
|
+
|
|
71
|
+
// Check scope
|
|
72
|
+
const scopeLogs = resourceLogs[0].scopeLogs;
|
|
73
|
+
expect(scopeLogs[0].scope.name).toBe("@typokit/otel");
|
|
74
|
+
|
|
75
|
+
// Check log record
|
|
76
|
+
const logRecord = scopeLogs[0].logRecords[0];
|
|
77
|
+
expect(logRecord.severityNumber).toBe(9); // INFO
|
|
78
|
+
expect(logRecord.severityText).toBe("INFO");
|
|
79
|
+
expect(logRecord.body.stringValue).toBe("test log message");
|
|
80
|
+
expect(logRecord.timeUnixNano).toBe(
|
|
81
|
+
new Date("2026-01-01T00:00:00.000Z").getTime() * 1_000_000,
|
|
82
|
+
);
|
|
83
|
+
} finally {
|
|
84
|
+
if (originalFetch !== undefined) {
|
|
85
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
86
|
+
originalFetch;
|
|
87
|
+
} else {
|
|
88
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should include traceId in log record for correlation", () => {
|
|
94
|
+
const fetchCalls: Array<{ url: string; init: Record<string, unknown> }> =
|
|
95
|
+
[];
|
|
96
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
97
|
+
"fetch"
|
|
98
|
+
];
|
|
99
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] = (
|
|
100
|
+
url: string,
|
|
101
|
+
init: Record<string, unknown>,
|
|
102
|
+
) => {
|
|
103
|
+
fetchCalls.push({ url, init });
|
|
104
|
+
return Promise.resolve({ ok: true });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const sink = new OtelLogSink();
|
|
109
|
+
const entry: LogEntry = {
|
|
110
|
+
level: "error",
|
|
111
|
+
message: "something failed",
|
|
112
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
113
|
+
traceId: "abcdef0123456789abcdef0123456789",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
sink.write(entry);
|
|
117
|
+
|
|
118
|
+
const body = JSON.parse(fetchCalls[0].init["body"] as string);
|
|
119
|
+
const logRecord = body.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
120
|
+
expect(logRecord.traceId).toBe("abcdef0123456789abcdef0123456789");
|
|
121
|
+
} finally {
|
|
122
|
+
if (originalFetch !== undefined) {
|
|
123
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
124
|
+
originalFetch;
|
|
125
|
+
} else {
|
|
126
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should include spanId from data field for correlation", () => {
|
|
132
|
+
const fetchCalls: Array<{ url: string; init: Record<string, unknown> }> =
|
|
133
|
+
[];
|
|
134
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
135
|
+
"fetch"
|
|
136
|
+
];
|
|
137
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] = (
|
|
138
|
+
url: string,
|
|
139
|
+
init: Record<string, unknown>,
|
|
140
|
+
) => {
|
|
141
|
+
fetchCalls.push({ url, init });
|
|
142
|
+
return Promise.resolve({ ok: true });
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const sink = new OtelLogSink();
|
|
147
|
+
const entry: LogEntry = {
|
|
148
|
+
level: "info",
|
|
149
|
+
message: "in span",
|
|
150
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
151
|
+
traceId: "trace123",
|
|
152
|
+
data: { spanId: "span456", userId: "u1" },
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
sink.write(entry);
|
|
156
|
+
|
|
157
|
+
const body = JSON.parse(fetchCalls[0].init["body"] as string);
|
|
158
|
+
const logRecord = body.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
159
|
+
expect(logRecord.traceId).toBe("trace123");
|
|
160
|
+
expect(logRecord.spanId).toBe("span456");
|
|
161
|
+
|
|
162
|
+
// spanId should NOT appear in attributes (used as top-level field)
|
|
163
|
+
const attrKeys = logRecord.attributes.map((a: { key: string }) => a.key);
|
|
164
|
+
expect(attrKeys.includes("data.spanId")).toBe(false);
|
|
165
|
+
// userId should appear in attributes
|
|
166
|
+
expect(attrKeys.includes("data.userId")).toBe(true);
|
|
167
|
+
} finally {
|
|
168
|
+
if (originalFetch !== undefined) {
|
|
169
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
170
|
+
originalFetch;
|
|
171
|
+
} else {
|
|
172
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("should map all log levels to correct OTel severity numbers", () => {
|
|
178
|
+
const fetchCalls: Array<{ url: string; init: Record<string, unknown> }> =
|
|
179
|
+
[];
|
|
180
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
181
|
+
"fetch"
|
|
182
|
+
];
|
|
183
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] = (
|
|
184
|
+
url: string,
|
|
185
|
+
init: Record<string, unknown>,
|
|
186
|
+
) => {
|
|
187
|
+
fetchCalls.push({ url, init });
|
|
188
|
+
return Promise.resolve({ ok: true });
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const sink = new OtelLogSink();
|
|
193
|
+
const levels = [
|
|
194
|
+
{ level: "trace", num: 1, text: "TRACE" },
|
|
195
|
+
{ level: "debug", num: 5, text: "DEBUG" },
|
|
196
|
+
{ level: "info", num: 9, text: "INFO" },
|
|
197
|
+
{ level: "warn", num: 13, text: "WARN" },
|
|
198
|
+
{ level: "error", num: 17, text: "ERROR" },
|
|
199
|
+
{ level: "fatal", num: 21, text: "FATAL" },
|
|
200
|
+
] as const;
|
|
201
|
+
|
|
202
|
+
for (const l of levels) {
|
|
203
|
+
sink.write({
|
|
204
|
+
level: l.level,
|
|
205
|
+
message: `msg-${l.level}`,
|
|
206
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
expect(fetchCalls).toHaveLength(6);
|
|
211
|
+
for (let i = 0; i < levels.length; i++) {
|
|
212
|
+
const body = JSON.parse(fetchCalls[i].init["body"] as string);
|
|
213
|
+
const logRecord = body.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
214
|
+
expect(logRecord.severityNumber).toBe(levels[i].num);
|
|
215
|
+
expect(logRecord.severityText).toBe(levels[i].text);
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
if (originalFetch !== undefined) {
|
|
219
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
220
|
+
originalFetch;
|
|
221
|
+
} else {
|
|
222
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should include route, phase, requestId, serverAdapter as OTLP attributes", () => {
|
|
228
|
+
const fetchCalls: Array<{ url: string; init: Record<string, unknown> }> =
|
|
229
|
+
[];
|
|
230
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
231
|
+
"fetch"
|
|
232
|
+
];
|
|
233
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] = (
|
|
234
|
+
url: string,
|
|
235
|
+
init: Record<string, unknown>,
|
|
236
|
+
) => {
|
|
237
|
+
fetchCalls.push({ url, init });
|
|
238
|
+
return Promise.resolve({ ok: true });
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const sink = new OtelLogSink();
|
|
243
|
+
sink.write({
|
|
244
|
+
level: "info",
|
|
245
|
+
message: "test",
|
|
246
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
247
|
+
route: "POST /items",
|
|
248
|
+
phase: "validation",
|
|
249
|
+
requestId: "req-789",
|
|
250
|
+
serverAdapter: "native",
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const body = JSON.parse(fetchCalls[0].init["body"] as string);
|
|
254
|
+
const attrs = body.resourceLogs[0].scopeLogs[0].logRecords[0].attributes;
|
|
255
|
+
const attrMap = new Map(
|
|
256
|
+
attrs.map((a: { key: string; value: { stringValue: string } }) => [
|
|
257
|
+
a.key,
|
|
258
|
+
a.value.stringValue,
|
|
259
|
+
]),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(attrMap.get("route")).toBe("POST /items");
|
|
263
|
+
expect(attrMap.get("phase")).toBe("validation");
|
|
264
|
+
expect(attrMap.get("requestId")).toBe("req-789");
|
|
265
|
+
expect(attrMap.get("serverAdapter")).toBe("native");
|
|
266
|
+
} finally {
|
|
267
|
+
if (originalFetch !== undefined) {
|
|
268
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
269
|
+
originalFetch;
|
|
270
|
+
} else {
|
|
271
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("should silently handle missing fetch", () => {
|
|
277
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
278
|
+
"fetch"
|
|
279
|
+
];
|
|
280
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const sink = new OtelLogSink();
|
|
284
|
+
// Should not throw
|
|
285
|
+
sink.write({
|
|
286
|
+
level: "info",
|
|
287
|
+
message: "no fetch",
|
|
288
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
289
|
+
});
|
|
290
|
+
} finally {
|
|
291
|
+
if (originalFetch !== undefined) {
|
|
292
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
293
|
+
originalFetch;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("createOtelLogSink", () => {
|
|
300
|
+
it("should return undefined when no telemetry config", () => {
|
|
301
|
+
const sink = createOtelLogSink();
|
|
302
|
+
expect(sink).toBeUndefined();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("should return undefined when tracing is disabled", () => {
|
|
306
|
+
const sink = createOtelLogSink({ tracing: false });
|
|
307
|
+
expect(sink).toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should return undefined when tracing.enabled is false", () => {
|
|
311
|
+
const sink = createOtelLogSink({ tracing: { enabled: false } });
|
|
312
|
+
expect(sink).toBeUndefined();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("should return OtelLogSink when tracing is true", () => {
|
|
316
|
+
const sink = createOtelLogSink({ tracing: true });
|
|
317
|
+
expect(sink).toBeDefined();
|
|
318
|
+
expect(sink).toBeInstanceOf(OtelLogSink);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("should return OtelLogSink when tracing is configured as object", () => {
|
|
322
|
+
const sink = createOtelLogSink({
|
|
323
|
+
tracing: {
|
|
324
|
+
enabled: true,
|
|
325
|
+
exporter: "otlp",
|
|
326
|
+
endpoint: "http://collector:4318/v1/traces",
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
expect(sink).toBeDefined();
|
|
330
|
+
expect(sink).toBeInstanceOf(OtelLogSink);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("should derive logs endpoint from traces endpoint", () => {
|
|
334
|
+
const fetchCalls: Array<{ url: string; init: Record<string, unknown> }> =
|
|
335
|
+
[];
|
|
336
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
337
|
+
"fetch"
|
|
338
|
+
];
|
|
339
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] = (
|
|
340
|
+
url: string,
|
|
341
|
+
init: Record<string, unknown>,
|
|
342
|
+
) => {
|
|
343
|
+
fetchCalls.push({ url, init });
|
|
344
|
+
return Promise.resolve({ ok: true });
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const sink = createOtelLogSink({
|
|
349
|
+
tracing: { enabled: true, endpoint: "http://collector:4318/v1/traces" },
|
|
350
|
+
});
|
|
351
|
+
expect(sink).toBeDefined();
|
|
352
|
+
|
|
353
|
+
sink!.write({
|
|
354
|
+
level: "info",
|
|
355
|
+
message: "test endpoint",
|
|
356
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
expect(fetchCalls[0].url).toBe("http://collector:4318/v1/logs");
|
|
360
|
+
} finally {
|
|
361
|
+
if (originalFetch !== undefined) {
|
|
362
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
363
|
+
originalFetch;
|
|
364
|
+
} else {
|
|
365
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("should use serviceName from telemetry config", () => {
|
|
371
|
+
const fetchCalls: Array<{ url: string; init: Record<string, unknown> }> =
|
|
372
|
+
[];
|
|
373
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
374
|
+
"fetch"
|
|
375
|
+
];
|
|
376
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] = (
|
|
377
|
+
url: string,
|
|
378
|
+
init: Record<string, unknown>,
|
|
379
|
+
) => {
|
|
380
|
+
fetchCalls.push({ url, init });
|
|
381
|
+
return Promise.resolve({ ok: true });
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const sink = createOtelLogSink({ tracing: true, serviceName: "my-app" });
|
|
386
|
+
expect(sink).toBeDefined();
|
|
387
|
+
|
|
388
|
+
sink!.write({
|
|
389
|
+
level: "info",
|
|
390
|
+
message: "test svc",
|
|
391
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const body = JSON.parse(fetchCalls[0].init["body"] as string);
|
|
395
|
+
const svcAttr = body.resourceLogs[0].resource.attributes[0];
|
|
396
|
+
expect(svcAttr.value.stringValue).toBe("my-app");
|
|
397
|
+
} finally {
|
|
398
|
+
if (originalFetch !== undefined) {
|
|
399
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
400
|
+
originalFetch;
|
|
401
|
+
} else {
|
|
402
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("OtelLogSink + StructuredLogger integration", () => {
|
|
409
|
+
it("should work alongside StdoutSink (both active simultaneously)", () => {
|
|
410
|
+
const testSink = new TestSink();
|
|
411
|
+
const otelCalls: Array<{ url: string; init: Record<string, unknown> }> = [];
|
|
412
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
413
|
+
"fetch"
|
|
414
|
+
];
|
|
415
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] = (
|
|
416
|
+
url: string,
|
|
417
|
+
init: Record<string, unknown>,
|
|
418
|
+
) => {
|
|
419
|
+
otelCalls.push({ url, init });
|
|
420
|
+
return Promise.resolve({ ok: true });
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const otelSink = new OtelLogSink({ serviceName: "integration-test" });
|
|
425
|
+
// Both sinks active on the same logger
|
|
426
|
+
const logger = new StructuredLogger(
|
|
427
|
+
{ level: "trace" },
|
|
428
|
+
{ traceId: "trace-abc", route: "GET /health" },
|
|
429
|
+
[testSink, otelSink],
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
logger.info("health check", { status: "ok" });
|
|
433
|
+
|
|
434
|
+
// TestSink received the entry
|
|
435
|
+
expect(testSink.entries).toHaveLength(1);
|
|
436
|
+
expect(testSink.entries[0].message).toBe("health check");
|
|
437
|
+
expect(testSink.entries[0].traceId).toBe("trace-abc");
|
|
438
|
+
|
|
439
|
+
// OtelLogSink sent to collector
|
|
440
|
+
expect(otelCalls).toHaveLength(1);
|
|
441
|
+
const body = JSON.parse(otelCalls[0].init["body"] as string);
|
|
442
|
+
const logRecord = body.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
443
|
+
expect(logRecord.body.stringValue).toBe("health check");
|
|
444
|
+
expect(logRecord.traceId).toBe("trace-abc");
|
|
445
|
+
expect(logRecord.severityText).toBe("INFO");
|
|
446
|
+
} finally {
|
|
447
|
+
if (originalFetch !== undefined) {
|
|
448
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
449
|
+
originalFetch;
|
|
450
|
+
} else {
|
|
451
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("should forward log entries with correct trace context from logger metadata", () => {
|
|
457
|
+
const otelCalls: Array<{ url: string; init: Record<string, unknown> }> = [];
|
|
458
|
+
const originalFetch = (globalThis as unknown as Record<string, unknown>)[
|
|
459
|
+
"fetch"
|
|
460
|
+
];
|
|
461
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] = (
|
|
462
|
+
url: string,
|
|
463
|
+
init: Record<string, unknown>,
|
|
464
|
+
) => {
|
|
465
|
+
otelCalls.push({ url, init });
|
|
466
|
+
return Promise.resolve({ ok: true });
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
const otelSink = new OtelLogSink();
|
|
471
|
+
const logger = new StructuredLogger(
|
|
472
|
+
{ level: "trace" },
|
|
473
|
+
{
|
|
474
|
+
traceId: "aaaa1111bbbb2222cccc3333dddd4444",
|
|
475
|
+
route: "POST /orders",
|
|
476
|
+
phase: "handler",
|
|
477
|
+
},
|
|
478
|
+
[otelSink],
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
logger.error("order failed", { orderId: "ord-1", spanId: "span-9876" });
|
|
482
|
+
|
|
483
|
+
const body = JSON.parse(otelCalls[0].init["body"] as string);
|
|
484
|
+
const logRecord = body.resourceLogs[0].scopeLogs[0].logRecords[0];
|
|
485
|
+
|
|
486
|
+
// Trace context for correlation
|
|
487
|
+
expect(logRecord.traceId).toBe("aaaa1111bbbb2222cccc3333dddd4444");
|
|
488
|
+
expect(logRecord.spanId).toBe("span-9876");
|
|
489
|
+
expect(logRecord.severityNumber).toBe(17); // ERROR
|
|
490
|
+
|
|
491
|
+
// Attributes should include route and phase
|
|
492
|
+
const attrs = logRecord.attributes;
|
|
493
|
+
const attrMap = new Map(
|
|
494
|
+
attrs.map((a: { key: string; value: { stringValue: string } }) => [
|
|
495
|
+
a.key,
|
|
496
|
+
a.value.stringValue,
|
|
497
|
+
]),
|
|
498
|
+
);
|
|
499
|
+
expect(attrMap.get("route")).toBe("POST /orders");
|
|
500
|
+
expect(attrMap.get("phase")).toBe("handler");
|
|
501
|
+
expect(attrMap.get("data.orderId")).toBe("ord-1");
|
|
502
|
+
} finally {
|
|
503
|
+
if (originalFetch !== undefined) {
|
|
504
|
+
(globalThis as unknown as Record<string, unknown>)["fetch"] =
|
|
505
|
+
originalFetch;
|
|
506
|
+
} else {
|
|
507
|
+
delete (globalThis as unknown as Record<string, unknown>)["fetch"];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { LogEntry, LogSink, TelemetryConfig } from "./types.js";
|
|
2
|
+
import { resolveTracingConfig } from "./tracing.js";
|
|
3
|
+
|
|
4
|
+
// OTel severity numbers per spec (https://opentelemetry.io/docs/specs/otel/logs/data-model/#severity-fields)
|
|
5
|
+
const SEVERITY_NUMBER: Record<string, number> = {
|
|
6
|
+
trace: 1,
|
|
7
|
+
debug: 5,
|
|
8
|
+
info: 9,
|
|
9
|
+
warn: 13,
|
|
10
|
+
error: 17,
|
|
11
|
+
fatal: 21,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const SEVERITY_TEXT: Record<string, string> = {
|
|
15
|
+
trace: "TRACE",
|
|
16
|
+
debug: "DEBUG",
|
|
17
|
+
info: "INFO",
|
|
18
|
+
warn: "WARN",
|
|
19
|
+
error: "ERROR",
|
|
20
|
+
fatal: "FATAL",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* OTel log sink that pushes structured log entries to an OTLP-compatible
|
|
25
|
+
* collector via HTTP POST. Includes trace context (traceId, spanId) for
|
|
26
|
+
* correlation with distributed traces.
|
|
27
|
+
*/
|
|
28
|
+
export class OtelLogSink implements LogSink {
|
|
29
|
+
private readonly endpoint: string;
|
|
30
|
+
private readonly serviceName: string;
|
|
31
|
+
|
|
32
|
+
constructor(options?: { endpoint?: string; serviceName?: string }) {
|
|
33
|
+
this.endpoint = options?.endpoint ?? "http://localhost:4318/v1/logs";
|
|
34
|
+
this.serviceName = options?.serviceName ?? "typokit";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
write(entry: LogEntry): void {
|
|
38
|
+
const fetchFn = (
|
|
39
|
+
globalThis as unknown as {
|
|
40
|
+
fetch?: (url: string, init: unknown) => Promise<unknown>;
|
|
41
|
+
}
|
|
42
|
+
).fetch;
|
|
43
|
+
if (!fetchFn) return;
|
|
44
|
+
|
|
45
|
+
const timeUnixNano = new Date(entry.timestamp).getTime() * 1_000_000;
|
|
46
|
+
const attributes = this.buildAttributes(entry);
|
|
47
|
+
|
|
48
|
+
const logRecord: Record<string, unknown> = {
|
|
49
|
+
timeUnixNano,
|
|
50
|
+
severityNumber: SEVERITY_NUMBER[entry.level] ?? 9,
|
|
51
|
+
severityText: SEVERITY_TEXT[entry.level] ?? "INFO",
|
|
52
|
+
body: { stringValue: entry.message },
|
|
53
|
+
attributes,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Include trace context for correlation
|
|
57
|
+
if (entry.traceId) {
|
|
58
|
+
logRecord["traceId"] = entry.traceId;
|
|
59
|
+
}
|
|
60
|
+
if (entry.data?.["spanId"]) {
|
|
61
|
+
logRecord["spanId"] = String(entry.data["spanId"]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const payload = {
|
|
65
|
+
resourceLogs: [
|
|
66
|
+
{
|
|
67
|
+
resource: {
|
|
68
|
+
attributes: [
|
|
69
|
+
{ key: "service.name", value: { stringValue: this.serviceName } },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
scopeLogs: [
|
|
73
|
+
{
|
|
74
|
+
scope: { name: "@typokit/otel" },
|
|
75
|
+
logRecords: [logRecord],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Fire-and-forget POST to OTLP endpoint
|
|
83
|
+
fetchFn(this.endpoint, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify(payload),
|
|
87
|
+
}).catch(() => {
|
|
88
|
+
// Silently ignore export failures
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private buildAttributes(
|
|
93
|
+
entry: LogEntry,
|
|
94
|
+
): Array<{ key: string; value: { stringValue: string } }> {
|
|
95
|
+
const attrs: Array<{ key: string; value: { stringValue: string } }> = [];
|
|
96
|
+
|
|
97
|
+
if (entry.route) {
|
|
98
|
+
attrs.push({ key: "route", value: { stringValue: entry.route } });
|
|
99
|
+
}
|
|
100
|
+
if (entry.phase) {
|
|
101
|
+
attrs.push({ key: "phase", value: { stringValue: entry.phase } });
|
|
102
|
+
}
|
|
103
|
+
if (entry.requestId) {
|
|
104
|
+
attrs.push({ key: "requestId", value: { stringValue: entry.requestId } });
|
|
105
|
+
}
|
|
106
|
+
if (entry.serverAdapter) {
|
|
107
|
+
attrs.push({
|
|
108
|
+
key: "serverAdapter",
|
|
109
|
+
value: { stringValue: entry.serverAdapter },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Include any extra data fields as attributes
|
|
114
|
+
if (entry.data) {
|
|
115
|
+
for (const [key, val] of Object.entries(entry.data)) {
|
|
116
|
+
if (key === "spanId") continue; // Already used as top-level field
|
|
117
|
+
if (val !== undefined && val !== null) {
|
|
118
|
+
attrs.push({
|
|
119
|
+
key: `data.${key}`,
|
|
120
|
+
value: { stringValue: String(val) },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return attrs;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Creates an OtelLogSink if tracing is configured and enabled.
|
|
132
|
+
* Returns undefined if tracing is not configured (opt-in behavior).
|
|
133
|
+
*/
|
|
134
|
+
export function createOtelLogSink(
|
|
135
|
+
telemetry?: TelemetryConfig,
|
|
136
|
+
): OtelLogSink | undefined {
|
|
137
|
+
if (!telemetry) return undefined;
|
|
138
|
+
|
|
139
|
+
const tracingConfig = resolveTracingConfig(telemetry);
|
|
140
|
+
if (!tracingConfig.enabled) return undefined;
|
|
141
|
+
|
|
142
|
+
const endpoint = tracingConfig.endpoint
|
|
143
|
+
? tracingConfig.endpoint.replace(/\/v1\/traces\/?$/, "/v1/logs")
|
|
144
|
+
: "http://localhost:4318/v1/logs";
|
|
145
|
+
|
|
146
|
+
return new OtelLogSink({
|
|
147
|
+
endpoint,
|
|
148
|
+
serviceName: tracingConfig.serviceName,
|
|
149
|
+
});
|
|
150
|
+
}
|