@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/metrics.ts ADDED
@@ -0,0 +1,337 @@
1
+ import type {
2
+ MetricsConfig,
3
+ MetricLabels,
4
+ MetricData,
5
+ MetricExporter,
6
+ HistogramDataPoint,
7
+ GaugeDataPoint,
8
+ CounterDataPoint,
9
+ TelemetryConfig,
10
+ } from "./types.js";
11
+
12
+ // ─── Metric Exporters ────────────────────────────────────────
13
+
14
+ /** Exports metrics to stdout as structured JSON (dev mode) */
15
+ export class ConsoleMetricExporter implements MetricExporter {
16
+ export(metrics: MetricData[]): void {
17
+ const proc = (
18
+ globalThis as unknown as {
19
+ process?: { stdout?: { write(s: string): void } };
20
+ }
21
+ ).process;
22
+ for (const metric of metrics) {
23
+ const output = JSON.stringify({ ...metric, exportKind: "metric" });
24
+ if (proc?.stdout?.write) {
25
+ proc.stdout.write(output + "\n");
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ /** No-op exporter that silently discards metrics */
32
+ export class NoopMetricExporter implements MetricExporter {
33
+ export(_metrics: MetricData[]): void {
34
+ // intentionally empty
35
+ }
36
+ }
37
+
38
+ /** Exports metrics to an OTLP-compatible HTTP endpoint */
39
+ export class OtlpMetricExporter implements MetricExporter {
40
+ private readonly endpoint: string;
41
+
42
+ constructor(endpoint?: string) {
43
+ this.endpoint = endpoint ?? "http://localhost:4318/v1/metrics";
44
+ }
45
+
46
+ export(metrics: MetricData[]): void {
47
+ const fetchFn = (
48
+ globalThis as unknown as {
49
+ fetch?: (url: string, init: unknown) => Promise<unknown>;
50
+ }
51
+ ).fetch;
52
+ if (fetchFn) {
53
+ const payload = {
54
+ resourceMetrics: [
55
+ {
56
+ resource: { attributes: [] },
57
+ scopeMetrics: [
58
+ {
59
+ scope: { name: "@typokit/otel" },
60
+ metrics: metrics.map((m) => ({
61
+ name: m.name,
62
+ ...(m.type === "histogram"
63
+ ? {
64
+ histogram: {
65
+ dataPoints: m.dataPoints.map((dp) => ({
66
+ attributes: labelsToAttributes(
67
+ dp.labels as MetricLabels,
68
+ ),
69
+ startTimeUnixNano:
70
+ new Date(dp.timestamp).getTime() * 1_000_000,
71
+ timeUnixNano:
72
+ new Date(dp.timestamp).getTime() * 1_000_000,
73
+ sum: dp.value,
74
+ count: 1,
75
+ })),
76
+ },
77
+ }
78
+ : m.type === "gauge"
79
+ ? {
80
+ gauge: {
81
+ dataPoints: m.dataPoints.map((dp) => ({
82
+ attributes: labelsToAttributes(
83
+ dp.labels as Partial<MetricLabels>,
84
+ ),
85
+ timeUnixNano:
86
+ new Date(dp.timestamp).getTime() * 1_000_000,
87
+ asInt: dp.value,
88
+ })),
89
+ },
90
+ }
91
+ : {
92
+ sum: {
93
+ dataPoints: m.dataPoints.map((dp) => ({
94
+ attributes: labelsToAttributes(
95
+ dp.labels as MetricLabels,
96
+ ),
97
+ startTimeUnixNano:
98
+ new Date(dp.timestamp).getTime() * 1_000_000,
99
+ timeUnixNano:
100
+ new Date(dp.timestamp).getTime() * 1_000_000,
101
+ asInt: dp.value,
102
+ })),
103
+ isMonotonic: true,
104
+ },
105
+ }),
106
+ })),
107
+ },
108
+ ],
109
+ },
110
+ ],
111
+ };
112
+
113
+ fetchFn(this.endpoint, {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify(payload),
117
+ }).catch(() => {
118
+ // Silently ignore export failures
119
+ });
120
+ }
121
+ }
122
+ }
123
+
124
+ function labelsToAttributes(
125
+ labels: Partial<MetricLabels>,
126
+ ): Array<{ key: string; value: { stringValue?: string; intValue?: number } }> {
127
+ const attrs: Array<{
128
+ key: string;
129
+ value: { stringValue?: string; intValue?: number };
130
+ }> = [];
131
+ if (labels.route !== undefined) {
132
+ attrs.push({ key: "http.route", value: { stringValue: labels.route } });
133
+ }
134
+ if (labels.method !== undefined) {
135
+ attrs.push({ key: "http.method", value: { stringValue: labels.method } });
136
+ }
137
+ if (labels.status !== undefined) {
138
+ attrs.push({ key: "http.status_code", value: { intValue: labels.status } });
139
+ }
140
+ return attrs;
141
+ }
142
+
143
+ // ─── Metrics Collector ───────────────────────────────────────
144
+
145
+ /**
146
+ * MetricsCollector records and stores request metrics.
147
+ * It manages three metric instruments:
148
+ * - http.server.request.duration (histogram) — request latency in ms
149
+ * - http.server.active_requests (gauge) — currently in-flight requests
150
+ * - http.server.error_count (counter) — error responses (status >= 400)
151
+ */
152
+ export class MetricsCollector {
153
+ private readonly enabled: boolean;
154
+ private readonly exporter: MetricExporter;
155
+ private readonly serviceName: string;
156
+
157
+ private readonly durations: HistogramDataPoint[] = [];
158
+ private readonly errors: CounterDataPoint[] = [];
159
+ private activeRequests = 0;
160
+ private readonly activeGaugeSnapshots: GaugeDataPoint[] = [];
161
+
162
+ constructor(options?: {
163
+ enabled?: boolean;
164
+ exporter?: MetricExporter;
165
+ serviceName?: string;
166
+ }) {
167
+ this.enabled = options?.enabled ?? true;
168
+ this.exporter = options?.exporter ?? new NoopMetricExporter();
169
+ this.serviceName = options?.serviceName ?? "typokit";
170
+ }
171
+
172
+ /** Record the start of a request (increments active_requests gauge) */
173
+ requestStart(): void {
174
+ if (!this.enabled) return;
175
+ this.activeRequests++;
176
+ }
177
+
178
+ /** Record the end of a request with its duration and labels */
179
+ requestEnd(labels: MetricLabels, durationMs: number): void {
180
+ if (!this.enabled) return;
181
+
182
+ this.activeRequests = Math.max(0, this.activeRequests - 1);
183
+
184
+ const timestamp = new Date().toISOString();
185
+
186
+ // Record duration histogram data point
187
+ this.durations.push({
188
+ labels,
189
+ value: durationMs,
190
+ timestamp,
191
+ });
192
+
193
+ // Record active requests gauge snapshot
194
+ this.activeGaugeSnapshots.push({
195
+ labels: {},
196
+ value: this.activeRequests,
197
+ timestamp,
198
+ });
199
+
200
+ // Record error counter if status >= 400
201
+ if (labels.status >= 400) {
202
+ this.errors.push({
203
+ labels,
204
+ value: 1,
205
+ timestamp,
206
+ });
207
+ }
208
+ }
209
+
210
+ /** Get current active request count */
211
+ getActiveRequests(): number {
212
+ return this.activeRequests;
213
+ }
214
+
215
+ /** Get the service name */
216
+ getServiceName(): string {
217
+ return this.serviceName;
218
+ }
219
+
220
+ /** Get all recorded duration data points */
221
+ getDurations(): HistogramDataPoint[] {
222
+ return [...this.durations];
223
+ }
224
+
225
+ /** Get all recorded error counter data points */
226
+ getErrors(): CounterDataPoint[] {
227
+ return [...this.errors];
228
+ }
229
+
230
+ /** Flush all collected metrics to the exporter */
231
+ flush(): void {
232
+ if (!this.enabled) return;
233
+
234
+ const metrics: MetricData[] = [];
235
+
236
+ if (this.durations.length > 0) {
237
+ metrics.push({
238
+ name: "http.server.request.duration",
239
+ type: "histogram",
240
+ dataPoints: [...this.durations],
241
+ });
242
+ }
243
+
244
+ if (this.activeGaugeSnapshots.length > 0) {
245
+ metrics.push({
246
+ name: "http.server.active_requests",
247
+ type: "gauge",
248
+ dataPoints: [...this.activeGaugeSnapshots],
249
+ });
250
+ }
251
+
252
+ if (this.errors.length > 0) {
253
+ metrics.push({
254
+ name: "http.server.error_count",
255
+ type: "counter",
256
+ dataPoints: [...this.errors],
257
+ });
258
+ }
259
+
260
+ if (metrics.length > 0) {
261
+ this.exporter.export(metrics);
262
+ }
263
+ }
264
+
265
+ /** Reset all collected metrics */
266
+ reset(): void {
267
+ this.durations.length = 0;
268
+ this.errors.length = 0;
269
+ this.activeGaugeSnapshots.length = 0;
270
+ this.activeRequests = 0;
271
+ }
272
+ }
273
+
274
+ // ─── Config Resolution ───────────────────────────────────────
275
+
276
+ /** Resolves a TelemetryConfig into a normalized MetricsConfig */
277
+ export function resolveMetricsConfig(
278
+ telemetry?: TelemetryConfig,
279
+ ): MetricsConfig {
280
+ if (!telemetry) {
281
+ return { enabled: true, exporter: "console" };
282
+ }
283
+
284
+ if (typeof telemetry.metrics === "boolean") {
285
+ return {
286
+ enabled: telemetry.metrics,
287
+ exporter: telemetry.exporter ?? "console",
288
+ endpoint: telemetry.endpoint,
289
+ serviceName: telemetry.serviceName,
290
+ };
291
+ }
292
+
293
+ if (typeof telemetry.metrics === "object") {
294
+ return {
295
+ enabled: telemetry.metrics.enabled ?? true,
296
+ exporter: telemetry.metrics.exporter ?? telemetry.exporter ?? "console",
297
+ endpoint: telemetry.metrics.endpoint ?? telemetry.endpoint,
298
+ serviceName: telemetry.metrics.serviceName ?? telemetry.serviceName,
299
+ };
300
+ }
301
+
302
+ return {
303
+ enabled: true,
304
+ exporter: telemetry.exporter ?? "console",
305
+ endpoint: telemetry.endpoint,
306
+ serviceName: telemetry.serviceName,
307
+ };
308
+ }
309
+
310
+ /** Creates the appropriate MetricExporter from config */
311
+ export function createMetricExporter(config: MetricsConfig): MetricExporter {
312
+ if (!config.enabled) {
313
+ return new NoopMetricExporter();
314
+ }
315
+ if (config.exporter === "otlp") {
316
+ return new OtlpMetricExporter(config.endpoint);
317
+ }
318
+ return new ConsoleMetricExporter();
319
+ }
320
+
321
+ /**
322
+ * Creates a MetricsCollector configured from TelemetryConfig.
323
+ * This is the main entry point for request-level metrics.
324
+ */
325
+ export function createMetricsCollector(
326
+ telemetry?: TelemetryConfig,
327
+ exporterOverride?: MetricExporter,
328
+ ): MetricsCollector {
329
+ const config = resolveMetricsConfig(telemetry);
330
+ const exporter = exporterOverride ?? createMetricExporter(config);
331
+
332
+ return new MetricsCollector({
333
+ exporter,
334
+ serviceName: config.serviceName,
335
+ enabled: config.enabled,
336
+ });
337
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Redacts sensitive fields from a data object based on glob-style patterns.
3
+ * Patterns like "*.password" match any key named "password" at any depth.
4
+ * Patterns like "authorization" match the exact key at root level.
5
+ */
6
+ export function redactFields(
7
+ data: Record<string, unknown>,
8
+ patterns: string[],
9
+ ): Record<string, unknown> {
10
+ if (patterns.length === 0) return data;
11
+
12
+ const fieldNames = new Set<string>();
13
+ for (const pattern of patterns) {
14
+ // "*.fieldName" → match fieldName at any depth
15
+ if (pattern.startsWith("*.")) {
16
+ fieldNames.add(pattern.slice(2));
17
+ } else {
18
+ // exact key name match at any depth
19
+ fieldNames.add(pattern);
20
+ }
21
+ }
22
+
23
+ return redactObject(data, fieldNames);
24
+ }
25
+
26
+ function redactObject(
27
+ obj: Record<string, unknown>,
28
+ fieldNames: Set<string>,
29
+ ): Record<string, unknown> {
30
+ const result: Record<string, unknown> = {};
31
+ for (const key of Object.keys(obj)) {
32
+ const value = obj[key];
33
+ if (fieldNames.has(key)) {
34
+ result[key] = "[REDACTED]";
35
+ } else if (isPlainObject(value)) {
36
+ result[key] = redactObject(value as Record<string, unknown>, fieldNames);
37
+ } else if (Array.isArray(value)) {
38
+ result[key] = value.map((item) =>
39
+ isPlainObject(item)
40
+ ? redactObject(item as Record<string, unknown>, fieldNames)
41
+ : item,
42
+ );
43
+ } else {
44
+ result[key] = value;
45
+ }
46
+ }
47
+ return result;
48
+ }
49
+
50
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
+ }