@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.
@@ -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
+ }