@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/src/logger.ts ADDED
@@ -0,0 +1,143 @@
1
+ import type { Logger } from "@typokit/types";
2
+ import type {
3
+ LogLevel,
4
+ LogEntry,
5
+ LoggingConfig,
6
+ LogMetadata,
7
+ LogSink,
8
+ } from "./types.js";
9
+ import { LOG_LEVELS } from "./types.js";
10
+ import { redactFields } from "./redact.js";
11
+
12
+ /** Default sink: writes structured JSON to stdout */
13
+ export class StdoutSink implements LogSink {
14
+ write(entry: LogEntry): void {
15
+ const output = JSON.stringify(entry);
16
+ // Use globalThis to avoid @types/node dependency
17
+ const proc = (
18
+ globalThis as unknown as {
19
+ process?: { stdout?: { write(s: string): void } };
20
+ }
21
+ ).process;
22
+ if (proc?.stdout?.write) {
23
+ proc.stdout.write(output + "\n");
24
+ }
25
+ }
26
+ }
27
+
28
+ const LEVEL_PRIORITY: Record<LogLevel, number> = {
29
+ trace: 0,
30
+ debug: 1,
31
+ info: 2,
32
+ warn: 3,
33
+ error: 4,
34
+ fatal: 5,
35
+ };
36
+
37
+ function isProduction(): boolean {
38
+ const proc = (
39
+ globalThis as unknown as {
40
+ process?: { env?: Record<string, string | undefined> };
41
+ }
42
+ ).process;
43
+ return proc?.env?.["NODE_ENV"] === "production";
44
+ }
45
+
46
+ /**
47
+ * StructuredLogger implements the Logger interface from @typokit/types.
48
+ * Automatically enriches log entries with request metadata and supports
49
+ * field redaction and configurable log levels.
50
+ */
51
+ export class StructuredLogger implements Logger {
52
+ private readonly minLevel: number;
53
+ private readonly redactPatterns: string[];
54
+ private readonly metadata: LogMetadata;
55
+ private readonly sinks: LogSink[];
56
+
57
+ constructor(
58
+ config?: LoggingConfig,
59
+ metadata?: LogMetadata,
60
+ sinks?: LogSink[],
61
+ ) {
62
+ const defaultLevel: LogLevel = isProduction() ? "info" : "debug";
63
+ this.minLevel = LEVEL_PRIORITY[config?.level ?? defaultLevel];
64
+ this.redactPatterns = config?.redact ?? [];
65
+ this.metadata = metadata ?? {};
66
+ this.sinks = sinks ?? [new StdoutSink()];
67
+ }
68
+
69
+ trace(message: string, data?: Record<string, unknown>): void {
70
+ this.log("trace", message, data);
71
+ }
72
+
73
+ debug(message: string, data?: Record<string, unknown>): void {
74
+ this.log("debug", message, data);
75
+ }
76
+
77
+ info(message: string, data?: Record<string, unknown>): void {
78
+ this.log("info", message, data);
79
+ }
80
+
81
+ warn(message: string, data?: Record<string, unknown>): void {
82
+ this.log("warn", message, data);
83
+ }
84
+
85
+ error(message: string, data?: Record<string, unknown>): void {
86
+ this.log("error", message, data);
87
+ }
88
+
89
+ fatal(message: string, data?: Record<string, unknown>): void {
90
+ this.log("fatal", message, data);
91
+ }
92
+
93
+ /** Create a child logger with additional/overridden metadata */
94
+ child(metadata: Partial<LogMetadata>): StructuredLogger {
95
+ return new StructuredLogger(
96
+ {
97
+ level: LOG_LEVELS[this.minLevel],
98
+ redact: this.redactPatterns,
99
+ },
100
+ { ...this.metadata, ...metadata },
101
+ this.sinks,
102
+ );
103
+ }
104
+
105
+ private log(
106
+ level: LogLevel,
107
+ message: string,
108
+ data?: Record<string, unknown>,
109
+ ): void {
110
+ if (LEVEL_PRIORITY[level] < this.minLevel) return;
111
+
112
+ const redactedData =
113
+ data && this.redactPatterns.length > 0
114
+ ? redactFields(data, this.redactPatterns)
115
+ : data;
116
+
117
+ const entry: LogEntry = {
118
+ level,
119
+ message,
120
+ timestamp: new Date().toISOString(),
121
+ ...(redactedData !== undefined ? { data: redactedData } : {}),
122
+ ...(this.metadata.traceId !== undefined
123
+ ? { traceId: this.metadata.traceId }
124
+ : {}),
125
+ ...(this.metadata.route !== undefined
126
+ ? { route: this.metadata.route }
127
+ : {}),
128
+ ...(this.metadata.phase !== undefined
129
+ ? { phase: this.metadata.phase }
130
+ : {}),
131
+ ...(this.metadata.requestId !== undefined
132
+ ? { requestId: this.metadata.requestId }
133
+ : {}),
134
+ ...(this.metadata.serverAdapter !== undefined
135
+ ? { serverAdapter: this.metadata.serverAdapter }
136
+ : {}),
137
+ };
138
+
139
+ for (const sink of this.sinks) {
140
+ sink.write(entry);
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,373 @@
1
+ import { describe, it, expect } from "@rstest/core";
2
+ import {
3
+ MetricsCollector,
4
+ ConsoleMetricExporter,
5
+ NoopMetricExporter,
6
+ OtlpMetricExporter,
7
+ resolveMetricsConfig,
8
+ createMetricExporter,
9
+ createMetricsCollector,
10
+ } from "./metrics.js";
11
+ import type { MetricData, MetricExporter } from "./types.js";
12
+
13
+ // ─── Test Helpers ────────────────────────────────────────────
14
+
15
+ class TestMetricExporter implements MetricExporter {
16
+ readonly exported: MetricData[][] = [];
17
+ export(metrics: MetricData[]): void {
18
+ this.exported.push(metrics);
19
+ }
20
+ }
21
+
22
+ // ─── MetricsCollector Tests ──────────────────────────────────
23
+
24
+ describe("MetricsCollector", () => {
25
+ it("should record request duration as histogram", () => {
26
+ const exporter = new TestMetricExporter();
27
+ const collector = new MetricsCollector({ exporter });
28
+
29
+ collector.requestStart();
30
+ collector.requestEnd({ route: "/users", method: "GET", status: 200 }, 42);
31
+ collector.flush();
32
+
33
+ expect(exporter.exported.length).toBe(1);
34
+ const metrics = exporter.exported[0];
35
+ const duration = metrics.find(
36
+ (m) => m.name === "http.server.request.duration",
37
+ );
38
+ expect(duration).toBeDefined();
39
+ expect(duration!.type).toBe("histogram");
40
+ expect(duration!.dataPoints.length).toBe(1);
41
+ expect(duration!.dataPoints[0].value).toBe(42);
42
+ expect(duration!.dataPoints[0].labels).toEqual({
43
+ route: "/users",
44
+ method: "GET",
45
+ status: 200,
46
+ });
47
+ });
48
+
49
+ it("should track active requests gauge", () => {
50
+ const exporter = new TestMetricExporter();
51
+ const collector = new MetricsCollector({ exporter });
52
+
53
+ collector.requestStart();
54
+ collector.requestStart();
55
+ expect(collector.getActiveRequests()).toBe(2);
56
+
57
+ collector.requestEnd({ route: "/a", method: "GET", status: 200 }, 10);
58
+ expect(collector.getActiveRequests()).toBe(1);
59
+
60
+ collector.requestEnd({ route: "/b", method: "POST", status: 201 }, 20);
61
+ expect(collector.getActiveRequests()).toBe(0);
62
+
63
+ collector.flush();
64
+
65
+ const metrics = exporter.exported[0];
66
+ const gauge = metrics.find((m) => m.name === "http.server.active_requests");
67
+ expect(gauge).toBeDefined();
68
+ expect(gauge!.type).toBe("gauge");
69
+ expect(gauge!.dataPoints.length).toBe(2);
70
+ });
71
+
72
+ it("should count errors for status >= 400", () => {
73
+ const exporter = new TestMetricExporter();
74
+ const collector = new MetricsCollector({ exporter });
75
+
76
+ collector.requestStart();
77
+ collector.requestEnd({ route: "/users", method: "GET", status: 200 }, 10);
78
+ collector.requestStart();
79
+ collector.requestEnd({ route: "/users", method: "POST", status: 400 }, 15);
80
+ collector.requestStart();
81
+ collector.requestEnd({ route: "/admin", method: "GET", status: 500 }, 50);
82
+ collector.flush();
83
+
84
+ const metrics = exporter.exported[0];
85
+ const errors = metrics.find((m) => m.name === "http.server.error_count");
86
+ expect(errors).toBeDefined();
87
+ expect(errors!.type).toBe("counter");
88
+ expect(errors!.dataPoints.length).toBe(2);
89
+ expect(errors!.dataPoints[0].labels.status).toBe(400);
90
+ expect(errors!.dataPoints[1].labels.status).toBe(500);
91
+ });
92
+
93
+ it("should not count 2xx/3xx as errors", () => {
94
+ const exporter = new TestMetricExporter();
95
+ const collector = new MetricsCollector({ exporter });
96
+
97
+ collector.requestStart();
98
+ collector.requestEnd({ route: "/ok", method: "GET", status: 200 }, 5);
99
+ collector.requestStart();
100
+ collector.requestEnd({ route: "/redirect", method: "GET", status: 302 }, 3);
101
+ collector.flush();
102
+
103
+ const metrics = exporter.exported[0];
104
+ const errors = metrics.find((m) => m.name === "http.server.error_count");
105
+ expect(errors).toBeUndefined();
106
+ });
107
+
108
+ it("should apply correct labels to metrics", () => {
109
+ const exporter = new TestMetricExporter();
110
+ const collector = new MetricsCollector({ exporter });
111
+
112
+ collector.requestStart();
113
+ collector.requestEnd(
114
+ { route: "/api/users/:id", method: "PUT", status: 204 },
115
+ 30,
116
+ );
117
+ collector.flush();
118
+
119
+ const metrics = exporter.exported[0];
120
+ const duration = metrics.find(
121
+ (m) => m.name === "http.server.request.duration",
122
+ )!;
123
+ const dp = duration.dataPoints[0];
124
+ expect(dp.labels).toEqual({
125
+ route: "/api/users/:id",
126
+ method: "PUT",
127
+ status: 204,
128
+ });
129
+ });
130
+
131
+ it("should not record when disabled", () => {
132
+ const exporter = new TestMetricExporter();
133
+ const collector = new MetricsCollector({ enabled: false, exporter });
134
+
135
+ collector.requestStart();
136
+ collector.requestEnd({ route: "/a", method: "GET", status: 200 }, 10);
137
+ collector.flush();
138
+
139
+ expect(exporter.exported.length).toBe(0);
140
+ expect(collector.getActiveRequests()).toBe(0);
141
+ });
142
+
143
+ it("should support reset", () => {
144
+ const exporter = new TestMetricExporter();
145
+ const collector = new MetricsCollector({ exporter });
146
+
147
+ collector.requestStart();
148
+ collector.requestEnd({ route: "/a", method: "GET", status: 200 }, 10);
149
+ collector.reset();
150
+
151
+ expect(collector.getActiveRequests()).toBe(0);
152
+ expect(collector.getDurations().length).toBe(0);
153
+ expect(collector.getErrors().length).toBe(0);
154
+
155
+ collector.flush();
156
+ expect(exporter.exported.length).toBe(0);
157
+ });
158
+
159
+ it("should not export when no metrics recorded", () => {
160
+ const exporter = new TestMetricExporter();
161
+ const collector = new MetricsCollector({ exporter });
162
+
163
+ collector.flush();
164
+ expect(exporter.exported.length).toBe(0);
165
+ });
166
+
167
+ it("should record multiple requests with different routes", () => {
168
+ const exporter = new TestMetricExporter();
169
+ const collector = new MetricsCollector({ exporter });
170
+
171
+ collector.requestStart();
172
+ collector.requestEnd({ route: "/users", method: "GET", status: 200 }, 10);
173
+ collector.requestStart();
174
+ collector.requestEnd({ route: "/posts", method: "GET", status: 200 }, 20);
175
+ collector.requestStart();
176
+ collector.requestEnd({ route: "/users", method: "POST", status: 201 }, 30);
177
+ collector.flush();
178
+
179
+ const durations = exporter.exported[0].find(
180
+ (m) => m.name === "http.server.request.duration",
181
+ )!;
182
+ expect(durations.dataPoints.length).toBe(3);
183
+ });
184
+
185
+ it("should store service name", () => {
186
+ const collector = new MetricsCollector({ serviceName: "my-service" });
187
+ expect(collector.getServiceName()).toBe("my-service");
188
+ });
189
+
190
+ it("should default service name to typokit", () => {
191
+ const collector = new MetricsCollector();
192
+ expect(collector.getServiceName()).toBe("typokit");
193
+ });
194
+
195
+ it("should not go below zero active requests", () => {
196
+ const exporter = new TestMetricExporter();
197
+ const collector = new MetricsCollector({ exporter });
198
+
199
+ // End without start
200
+ collector.requestEnd({ route: "/a", method: "GET", status: 200 }, 5);
201
+ expect(collector.getActiveRequests()).toBe(0);
202
+ });
203
+ });
204
+
205
+ // ─── Config Resolution Tests ─────────────────────────────────
206
+
207
+ describe("resolveMetricsConfig", () => {
208
+ it("should return defaults when no config", () => {
209
+ const config = resolveMetricsConfig();
210
+ expect(config.enabled).toBe(true);
211
+ expect(config.exporter).toBe("console");
212
+ });
213
+
214
+ it("should handle boolean metrics: true", () => {
215
+ const config = resolveMetricsConfig({ metrics: true });
216
+ expect(config.enabled).toBe(true);
217
+ expect(config.exporter).toBe("console");
218
+ });
219
+
220
+ it("should handle boolean metrics: false", () => {
221
+ const config = resolveMetricsConfig({ metrics: false });
222
+ expect(config.enabled).toBe(false);
223
+ });
224
+
225
+ it("should handle object metrics config", () => {
226
+ const config = resolveMetricsConfig({
227
+ metrics: {
228
+ enabled: true,
229
+ exporter: "otlp",
230
+ endpoint: "http://custom:4318",
231
+ serviceName: "test-svc",
232
+ },
233
+ });
234
+ expect(config.enabled).toBe(true);
235
+ expect(config.exporter).toBe("otlp");
236
+ expect(config.endpoint).toBe("http://custom:4318");
237
+ expect(config.serviceName).toBe("test-svc");
238
+ });
239
+
240
+ it("should inherit top-level exporter and serviceName", () => {
241
+ const config = resolveMetricsConfig({
242
+ metrics: true,
243
+ exporter: "otlp",
244
+ serviceName: "top-level",
245
+ endpoint: "http://top:4318",
246
+ });
247
+ expect(config.exporter).toBe("otlp");
248
+ expect(config.serviceName).toBe("top-level");
249
+ expect(config.endpoint).toBe("http://top:4318");
250
+ });
251
+
252
+ it("should prefer nested config over top-level", () => {
253
+ const config = resolveMetricsConfig({
254
+ metrics: { exporter: "otlp", serviceName: "nested" },
255
+ exporter: "console",
256
+ serviceName: "top-level",
257
+ });
258
+ expect(config.exporter).toBe("otlp");
259
+ expect(config.serviceName).toBe("nested");
260
+ });
261
+
262
+ it("should default to enabled when metrics not specified", () => {
263
+ const config = resolveMetricsConfig({ exporter: "otlp" });
264
+ expect(config.enabled).toBe(true);
265
+ expect(config.exporter).toBe("otlp");
266
+ });
267
+ });
268
+
269
+ // ─── Exporter Factory Tests ──────────────────────────────────
270
+
271
+ describe("createMetricExporter", () => {
272
+ it("should create NoopMetricExporter when disabled", () => {
273
+ const exporter = createMetricExporter({ enabled: false });
274
+ expect(exporter).toBeInstanceOf(NoopMetricExporter);
275
+ });
276
+
277
+ it("should create ConsoleMetricExporter by default", () => {
278
+ const exporter = createMetricExporter({
279
+ enabled: true,
280
+ exporter: "console",
281
+ });
282
+ expect(exporter).toBeInstanceOf(ConsoleMetricExporter);
283
+ });
284
+
285
+ it("should create OtlpMetricExporter for otlp", () => {
286
+ const exporter = createMetricExporter({ enabled: true, exporter: "otlp" });
287
+ expect(exporter).toBeInstanceOf(OtlpMetricExporter);
288
+ });
289
+ });
290
+
291
+ // ─── createMetricsCollector Tests ────────────────────────────
292
+
293
+ describe("createMetricsCollector", () => {
294
+ it("should create collector from TelemetryConfig", () => {
295
+ const collector = createMetricsCollector({
296
+ metrics: true,
297
+ serviceName: "test",
298
+ });
299
+ expect(collector.getServiceName()).toBe("test");
300
+ });
301
+
302
+ it("should accept exporter override", () => {
303
+ const exporter = new TestMetricExporter();
304
+ const collector = createMetricsCollector({ metrics: true }, exporter);
305
+ collector.requestStart();
306
+ collector.requestEnd({ route: "/a", method: "GET", status: 200 }, 5);
307
+ collector.flush();
308
+ expect(exporter.exported.length).toBe(1);
309
+ });
310
+
311
+ it("should be disabled when metrics: false", () => {
312
+ const exporter = new TestMetricExporter();
313
+ const collector = createMetricsCollector({ metrics: false }, exporter);
314
+ collector.requestStart();
315
+ collector.requestEnd({ route: "/a", method: "GET", status: 200 }, 5);
316
+ collector.flush();
317
+ expect(exporter.exported.length).toBe(0);
318
+ });
319
+ });
320
+
321
+ // ─── Exporter Behavior Tests ─────────────────────────────────
322
+
323
+ describe("ConsoleMetricExporter", () => {
324
+ it("should write JSON to stdout", () => {
325
+ const written: string[] = [];
326
+ const originalProcess = (globalThis as unknown as Record<string, unknown>)
327
+ .process;
328
+ (globalThis as unknown as Record<string, unknown>).process = {
329
+ stdout: {
330
+ write: (s: string) => {
331
+ written.push(s);
332
+ },
333
+ },
334
+ env: {},
335
+ };
336
+
337
+ const exporter = new ConsoleMetricExporter();
338
+ exporter.export([
339
+ {
340
+ name: "http.server.request.duration",
341
+ type: "histogram",
342
+ dataPoints: [
343
+ {
344
+ labels: { route: "/a", method: "GET", status: 200 },
345
+ value: 42,
346
+ timestamp: "2026-01-01T00:00:00Z",
347
+ },
348
+ ],
349
+ },
350
+ ]);
351
+
352
+ (globalThis as unknown as Record<string, unknown>).process =
353
+ originalProcess;
354
+
355
+ expect(written.length).toBe(1);
356
+ const parsed = JSON.parse(written[0].trim());
357
+ expect(parsed.exportKind).toBe("metric");
358
+ expect(parsed.name).toBe("http.server.request.duration");
359
+ });
360
+ });
361
+
362
+ describe("NoopMetricExporter", () => {
363
+ it("should not throw", () => {
364
+ const exporter = new NoopMetricExporter();
365
+ exporter.export([
366
+ {
367
+ name: "test",
368
+ type: "counter",
369
+ dataPoints: [],
370
+ },
371
+ ]);
372
+ });
373
+ });