@bleedingdev/modern-js-server-runtime-extensions 0.0.0-trusted-publisher-bootstrap

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/cjs/contractGateAutopilot.js +162 -0
  4. package/dist/cjs/contractGateSnapshotStore.js +253 -0
  5. package/dist/cjs/env.js +58 -0
  6. package/dist/cjs/index.js +162 -0
  7. package/dist/cjs/mfCache.js +106 -0
  8. package/dist/cjs/moduleFederationCss.js +285 -0
  9. package/dist/cjs/runtimeFallbackSignal.js +311 -0
  10. package/dist/cjs/telemetry.js +373 -0
  11. package/dist/cjs/telemetryCore.js +819 -0
  12. package/dist/esm/contractGateAutopilot.mjs +124 -0
  13. package/dist/esm/contractGateSnapshotStore.mjs +190 -0
  14. package/dist/esm/env.mjs +17 -0
  15. package/dist/esm/index.mjs +6 -0
  16. package/dist/esm/mfCache.mjs +55 -0
  17. package/dist/esm/moduleFederationCss.mjs +225 -0
  18. package/dist/esm/runtimeFallbackSignal.mjs +222 -0
  19. package/dist/esm/telemetry.mjs +275 -0
  20. package/dist/esm/telemetryCore.mjs +759 -0
  21. package/dist/esm-node/contractGateAutopilot.mjs +125 -0
  22. package/dist/esm-node/contractGateSnapshotStore.mjs +192 -0
  23. package/dist/esm-node/env.mjs +18 -0
  24. package/dist/esm-node/index.mjs +7 -0
  25. package/dist/esm-node/mfCache.mjs +56 -0
  26. package/dist/esm-node/moduleFederationCss.mjs +226 -0
  27. package/dist/esm-node/runtimeFallbackSignal.mjs +223 -0
  28. package/dist/esm-node/telemetry.mjs +276 -0
  29. package/dist/esm-node/telemetryCore.mjs +760 -0
  30. package/dist/types/contractGateAutopilot.d.ts +35 -0
  31. package/dist/types/contractGateSnapshotStore.d.ts +57 -0
  32. package/dist/types/env.d.ts +40 -0
  33. package/dist/types/index.d.ts +6 -0
  34. package/dist/types/mfCache.d.ts +27 -0
  35. package/dist/types/moduleFederationCss.d.ts +87 -0
  36. package/dist/types/runtimeFallbackSignal.d.ts +94 -0
  37. package/dist/types/telemetry.d.ts +12 -0
  38. package/dist/types/telemetryCore.d.ts +257 -0
  39. package/package.json +69 -0
  40. package/rslib.config.mts +4 -0
  41. package/rstest.config.mts +7 -0
  42. package/src/contractGateAutopilot.ts +247 -0
  43. package/src/contractGateSnapshotStore.ts +420 -0
  44. package/src/env.ts +63 -0
  45. package/src/index.ts +84 -0
  46. package/src/mfCache.ts +119 -0
  47. package/src/moduleFederationCss.ts +473 -0
  48. package/src/runtimeFallbackSignal.ts +584 -0
  49. package/src/telemetry.ts +554 -0
  50. package/src/telemetryCore.ts +1332 -0
  51. package/tests/contractGateAutopilot.test.ts +203 -0
  52. package/tests/contractGateSnapshotStore.test.ts +223 -0
  53. package/tests/env.test.ts +73 -0
  54. package/tests/helpers.ts +19 -0
  55. package/tests/mfCache.test.ts +150 -0
  56. package/tests/moduleFederationCss.test.ts +392 -0
  57. package/tests/registration.test.ts +112 -0
  58. package/tests/telemetry.test.ts +360 -0
  59. package/tests/telemetryAutopilot.test.ts +993 -0
  60. package/tests/telemetryCanaryOrchestrator.test.ts +140 -0
  61. package/tests/telemetryLifecycle.test.ts +168 -0
  62. package/tests/telemetryTraceparent.test.ts +167 -0
  63. package/tests/tsconfig.json +11 -0
  64. package/tsconfig.json +10 -0
@@ -0,0 +1,1332 @@
1
+ import type { LogEvent, Metrics, MonitorEvent } from '@modern-js/types';
2
+
3
+ export type TelemetrySignalType = 'log' | 'metric' | 'trace';
4
+
5
+ export interface TelemetryEnvelope {
6
+ timestamp: number;
7
+ service: string;
8
+ module: string;
9
+ environment: string;
10
+ signalType: TelemetrySignalType;
11
+ name: string;
12
+ level?: string;
13
+ value?: number;
14
+ unit?: string;
15
+ traceId?: string;
16
+ spanId?: string;
17
+ parentSpanId?: string;
18
+ tags?: Record<string, string>;
19
+ attributes?: Record<string, unknown>;
20
+ error?: {
21
+ name?: string;
22
+ message: string;
23
+ stack?: string;
24
+ };
25
+ }
26
+
27
+ export interface TelemetryExporter {
28
+ name: string;
29
+ init?: (context: {
30
+ service: string;
31
+ module: string;
32
+ environment: string;
33
+ }) => void | Promise<void>;
34
+ emit: (batch: TelemetryEnvelope[]) => void | Promise<void>;
35
+ flush?: () => void | Promise<void>;
36
+ shutdown?: () => void | Promise<void>;
37
+ }
38
+
39
+ export interface TelemetryRegistryOptions {
40
+ service: string;
41
+ module: string;
42
+ environment: string;
43
+ samplingRate?: number;
44
+ flushIntervalMs?: number;
45
+ maxBatchSize?: number;
46
+ maxQueueSize?: number;
47
+ redactionKeys?: string[];
48
+ slo?: {
49
+ queueUtilizationWarnThreshold?: number;
50
+ queueDroppedWarnThreshold?: number;
51
+ alertCooldownMs?: number;
52
+ onAlert?: (alert: TelemetrySloAlert) => void;
53
+ };
54
+ }
55
+
56
+ export type TelemetrySloAlertType = 'queue.utilization' | 'queue.drop';
57
+
58
+ export interface TelemetrySloAlert {
59
+ timestamp: number;
60
+ service: string;
61
+ module: string;
62
+ environment: string;
63
+ type: TelemetrySloAlertType;
64
+ value: number;
65
+ threshold: number;
66
+ queueDepth: number;
67
+ queueCapacity: number;
68
+ queueUtilization: number;
69
+ totalDropped: number;
70
+ }
71
+
72
+ export interface TelemetryQueueStats {
73
+ depth: number;
74
+ capacity: number;
75
+ utilization: number;
76
+ pendingDropped: number;
77
+ totalDropped: number;
78
+ }
79
+
80
+ export type TelemetryCanaryState = 'canary' | 'promoted' | 'rolled_back';
81
+ export type TelemetryCanaryAction = 'hold' | 'promote' | 'rollback';
82
+ export type TelemetryCanaryFailureReason =
83
+ | 'queue_utilization'
84
+ | 'queue_dropped'
85
+ | 'unhealthy_exporter'
86
+ | 'contract_gate_missing'
87
+ | 'contract_gate_failed';
88
+
89
+ export interface TelemetryCanaryFailure {
90
+ reason: TelemetryCanaryFailureReason;
91
+ gate?: string;
92
+ message?: string;
93
+ threshold?: number;
94
+ value?: number;
95
+ }
96
+
97
+ export interface TelemetryCanaryContractGateStatus {
98
+ name: string;
99
+ passed: boolean;
100
+ reason?: string;
101
+ updatedAt: number;
102
+ }
103
+
104
+ export interface TelemetryCanaryDecision {
105
+ timestamp: number;
106
+ action: TelemetryCanaryAction;
107
+ state: TelemetryCanaryState;
108
+ consecutiveHealthy: number;
109
+ consecutiveFailures: number;
110
+ failures: TelemetryCanaryFailure[];
111
+ queueStats: TelemetryQueueStats;
112
+ unhealthyExporterCount: number;
113
+ contractGates: TelemetryCanaryContractGateStatus[];
114
+ }
115
+
116
+ export interface TelemetryCanaryStatusSnapshot {
117
+ timestamp: number;
118
+ state: TelemetryCanaryState;
119
+ consecutiveHealthy: number;
120
+ consecutiveFailures: number;
121
+ queueStats: TelemetryQueueStats;
122
+ unhealthyExporterCount: number;
123
+ requiredContractGates: string[];
124
+ contractGates: TelemetryCanaryContractGateStatus[];
125
+ failurePreview: TelemetryCanaryFailure[];
126
+ }
127
+
128
+ export interface TelemetryCanaryOrchestratorOptions {
129
+ registry: TelemetryRegistry;
130
+ evaluationIntervalMs?: number;
131
+ minConsecutiveHealthyEvaluations?: number;
132
+ rollbackConsecutiveFailures?: number;
133
+ maxQueueUtilization?: number;
134
+ maxTotalDropped?: number;
135
+ maxUnhealthyExporters?: number;
136
+ requiredContractGates?: string[];
137
+ onEvaluate?: (decision: TelemetryCanaryDecision) => void;
138
+ onPromote?: (decision: TelemetryCanaryDecision) => void;
139
+ onRollback?: (decision: TelemetryCanaryDecision) => void;
140
+ }
141
+
142
+ export interface TelemetryExporterHealthStatus {
143
+ name: string;
144
+ healthy: boolean;
145
+ failures: number;
146
+ lastError?: string;
147
+ lastSuccessAt?: number;
148
+ lastFailureAt?: number;
149
+ }
150
+
151
+ export class TelemetryStartupHealthError extends Error {
152
+ readonly code = 'TELEMETRY_EXPORTER_STARTUP_HEALTH_FAILED';
153
+
154
+ readonly failedExporters: TelemetryExporterHealthStatus[];
155
+
156
+ constructor(failedExporters: TelemetryExporterHealthStatus[]) {
157
+ super(
158
+ `Telemetry startup health check failed for exporters: ${failedExporters.map(item => item.name).join(', ')}`,
159
+ );
160
+ this.name = 'TelemetryStartupHealthError';
161
+ this.failedExporters = failedExporters;
162
+ }
163
+ }
164
+
165
+ export interface OtlpExporterOptions {
166
+ endpoint?: string;
167
+ headers?: Record<string, string>;
168
+ timeoutMs?: number;
169
+ }
170
+
171
+ export interface VictoriaMetricsExporterOptions extends OtlpExporterOptions {
172
+ metricPrefix?: string;
173
+ }
174
+
175
+ const DEFAULT_OTLP_ENDPOINT = 'http://127.0.0.1:4318/v1/logs';
176
+ const DEFAULT_VM_ENDPOINT = 'http://127.0.0.1:8428/api/v1/import/prometheus';
177
+
178
+ type TelemetryMetricsTags = Record<string, unknown>;
179
+
180
+ type TelemetryMetricsPrefixOrTags = string | TelemetryMetricsTags;
181
+
182
+ const DEFAULT_TIMEOUT_MS = 5_000;
183
+
184
+ function clamp(value: number, min: number, max: number) {
185
+ return Math.max(min, Math.min(max, value));
186
+ }
187
+
188
+ function isRecord(value: unknown): value is Record<string, unknown> {
189
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
190
+ }
191
+
192
+ function redactObject(
193
+ value: unknown,
194
+ redactionKeys: Set<string>,
195
+ ): Record<string, unknown> | undefined {
196
+ if (!isRecord(value)) {
197
+ return undefined;
198
+ }
199
+
200
+ const output: Record<string, unknown> = {};
201
+ for (const [key, nested] of Object.entries(value)) {
202
+ if (redactionKeys.has(key)) {
203
+ output[key] = '[REDACTED]';
204
+ continue;
205
+ }
206
+
207
+ if (Array.isArray(nested)) {
208
+ output[key] = nested.map(item => {
209
+ if (isRecord(item)) {
210
+ return redactObject(item, redactionKeys);
211
+ }
212
+ return item;
213
+ });
214
+ continue;
215
+ }
216
+
217
+ if (isRecord(nested)) {
218
+ output[key] = redactObject(nested, redactionKeys);
219
+ continue;
220
+ }
221
+
222
+ output[key] = nested;
223
+ }
224
+
225
+ return output;
226
+ }
227
+
228
+ function normalizeLabels(labels: Record<string, unknown> | undefined) {
229
+ if (!labels) {
230
+ return undefined;
231
+ }
232
+
233
+ const normalized: Record<string, string> = {};
234
+ for (const [key, value] of Object.entries(labels)) {
235
+ if (value === undefined || value === null) {
236
+ continue;
237
+ }
238
+ normalized[key] = String(value);
239
+ }
240
+
241
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
242
+ }
243
+
244
+ function extractError(args: unknown[]): TelemetryEnvelope['error'] | undefined {
245
+ for (const arg of args) {
246
+ if (arg instanceof Error) {
247
+ return {
248
+ name: arg.name,
249
+ message: arg.message,
250
+ stack: arg.stack,
251
+ };
252
+ }
253
+ }
254
+
255
+ return undefined;
256
+ }
257
+
258
+ export function toTelemetryEnvelope(
259
+ event: MonitorEvent,
260
+ input: {
261
+ service: string;
262
+ module: string;
263
+ environment: string;
264
+ traceId?: string;
265
+ spanId?: string;
266
+ attributes?: Record<string, unknown>;
267
+ },
268
+ ): TelemetryEnvelope {
269
+ const base: Pick<
270
+ TelemetryEnvelope,
271
+ | 'timestamp'
272
+ | 'service'
273
+ | 'module'
274
+ | 'environment'
275
+ | 'traceId'
276
+ | 'spanId'
277
+ | 'attributes'
278
+ > = {
279
+ timestamp: Date.now(),
280
+ service: input.service,
281
+ module: input.module,
282
+ environment: input.environment,
283
+ ...(input.traceId ? { traceId: input.traceId } : {}),
284
+ ...(input.spanId ? { spanId: input.spanId } : {}),
285
+ ...(input.attributes ? { attributes: input.attributes } : {}),
286
+ };
287
+
288
+ if (event.type === 'log') {
289
+ const payload = event.payload as LogEvent['payload'];
290
+ const args = payload.args || [];
291
+ const signalType: TelemetrySignalType =
292
+ payload.level === 'trace' ? 'trace' : 'log';
293
+ return {
294
+ ...base,
295
+ signalType,
296
+ name: payload.message,
297
+ level: payload.level,
298
+ attributes: {
299
+ ...(base.attributes || {}),
300
+ args,
301
+ },
302
+ error: extractError(args),
303
+ };
304
+ }
305
+
306
+ if (event.type === 'timing') {
307
+ return {
308
+ ...base,
309
+ signalType: 'metric',
310
+ name: event.payload.name,
311
+ value: event.payload.dur,
312
+ unit: 'ms',
313
+ tags: normalizeLabels(event.payload.tags),
314
+ attributes: {
315
+ ...(base.attributes || {}),
316
+ desc: event.payload.desc,
317
+ args: event.payload.args,
318
+ },
319
+ };
320
+ }
321
+
322
+ return {
323
+ ...base,
324
+ signalType: 'metric',
325
+ name: event.payload.name,
326
+ value: 1,
327
+ unit: 'count',
328
+ tags: normalizeLabels(event.payload.tags),
329
+ attributes: {
330
+ ...(base.attributes || {}),
331
+ args: event.payload.args,
332
+ },
333
+ };
334
+ }
335
+
336
+ async function postWithTimeout(options: {
337
+ endpoint: string;
338
+ body: string;
339
+ headers: Record<string, string>;
340
+ timeoutMs: number;
341
+ }) {
342
+ const controller = new AbortController();
343
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
344
+ if (typeof (timer as NodeJS.Timeout).unref === 'function') {
345
+ (timer as NodeJS.Timeout).unref();
346
+ }
347
+
348
+ try {
349
+ const response = await fetch(options.endpoint, {
350
+ method: 'POST',
351
+ body: options.body,
352
+ headers: options.headers,
353
+ signal: controller.signal,
354
+ });
355
+
356
+ if (!response.ok) {
357
+ throw new Error(
358
+ `Telemetry exporter request failed: ${response.status} ${response.statusText}`,
359
+ );
360
+ }
361
+ } finally {
362
+ clearTimeout(timer);
363
+ }
364
+ }
365
+
366
+ function sanitizeMetricName(value: string) {
367
+ return value.replace(/[^a-zA-Z0-9_:]/g, '_').replace(/_+/g, '_');
368
+ }
369
+
370
+ function escapeLabelValue(value: string) {
371
+ return value
372
+ .replace(/\\/g, '\\\\')
373
+ .replace(/"/g, '\\"')
374
+ .replace(/\n/g, '\\n');
375
+ }
376
+
377
+ function toPrometheusLine(
378
+ envelope: TelemetryEnvelope,
379
+ metricPrefix: string,
380
+ ): string {
381
+ const metricName = sanitizeMetricName(
382
+ `${metricPrefix}_${envelope.signalType}_${envelope.name}`,
383
+ );
384
+ const labels: Record<string, string> = {
385
+ service: envelope.service,
386
+ module: envelope.module,
387
+ environment: envelope.environment,
388
+ ...(envelope.level ? { level: envelope.level } : {}),
389
+ ...(envelope.traceId ? { trace_id: envelope.traceId } : {}),
390
+ ...(envelope.spanId ? { span_id: envelope.spanId } : {}),
391
+ ...(envelope.tags || {}),
392
+ };
393
+
394
+ const labelPairs = Object.entries(labels)
395
+ .sort(([a], [b]) => a.localeCompare(b))
396
+ .map(
397
+ ([key, value]) =>
398
+ `${sanitizeMetricName(key)}="${escapeLabelValue(value)}"`,
399
+ );
400
+ const labelText = labelPairs.length > 0 ? `{${labelPairs.join(',')}}` : '';
401
+ const value =
402
+ typeof envelope.value === 'number' && Number.isFinite(envelope.value)
403
+ ? envelope.value
404
+ : 1;
405
+ const timestampMs = envelope.timestamp;
406
+ return `${metricName}${labelText} ${value} ${timestampMs}`;
407
+ }
408
+
409
+ export function createOtlpTelemetryExporter(
410
+ options: OtlpExporterOptions = {},
411
+ ): TelemetryExporter {
412
+ const endpoint = options.endpoint || DEFAULT_OTLP_ENDPOINT;
413
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
414
+ const headers = {
415
+ 'content-type': 'application/json',
416
+ ...(options.headers || {}),
417
+ };
418
+
419
+ return {
420
+ name: 'otlp',
421
+ async emit(batch) {
422
+ if (batch.length === 0) {
423
+ return;
424
+ }
425
+
426
+ const body = JSON.stringify({
427
+ resource: {
428
+ service: batch[0]?.service,
429
+ module: batch[0]?.module,
430
+ environment: batch[0]?.environment,
431
+ },
432
+ emittedAt: Date.now(),
433
+ events: batch,
434
+ });
435
+
436
+ await postWithTimeout({
437
+ endpoint,
438
+ body,
439
+ headers,
440
+ timeoutMs,
441
+ });
442
+ },
443
+ };
444
+ }
445
+
446
+ export function createVictoriaMetricsTelemetryExporter(
447
+ options: VictoriaMetricsExporterOptions = {},
448
+ ): TelemetryExporter {
449
+ const endpoint = options.endpoint || DEFAULT_VM_ENDPOINT;
450
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
451
+ const metricPrefix = sanitizeMetricName(options.metricPrefix || 'modernjs');
452
+ const headers = {
453
+ 'content-type': 'text/plain; version=0.0.4',
454
+ ...(options.headers || {}),
455
+ };
456
+
457
+ return {
458
+ name: 'victoria-metrics',
459
+ async emit(batch) {
460
+ if (batch.length === 0) {
461
+ return;
462
+ }
463
+
464
+ const lines = batch.map(item => toPrometheusLine(item, metricPrefix));
465
+ await postWithTimeout({
466
+ endpoint,
467
+ body: `${lines.join('\n')}\n`,
468
+ headers,
469
+ timeoutMs,
470
+ });
471
+ },
472
+ };
473
+ }
474
+
475
+ export class TelemetryRegistry {
476
+ private readonly exporters: TelemetryExporter[] = [];
477
+ private readonly queue: TelemetryEnvelope[] = [];
478
+ private readonly redactionKeys: Set<string>;
479
+ private readonly service: string;
480
+ private readonly module: string;
481
+ private readonly environment: string;
482
+ private readonly samplingRate: number;
483
+ private readonly maxBatchSize: number;
484
+ private readonly maxQueueSize: number;
485
+ private readonly flushIntervalMs: number;
486
+ private readonly flushTimer?: ReturnType<typeof setInterval>;
487
+ private droppedCount = 0;
488
+ private totalDroppedCount = 0;
489
+ private flushing: Promise<void> | null = null;
490
+ private readonly exporterHealth = new Map<
491
+ string,
492
+ TelemetryExporterHealthStatus
493
+ >();
494
+ private readonly queueUtilizationWarnThreshold: number;
495
+ private readonly queueDroppedWarnThreshold: number;
496
+ private readonly alertCooldownMs: number;
497
+ private readonly onSloAlert?: (alert: TelemetrySloAlert) => void;
498
+ private readonly lastSloAlertAt = new Map<TelemetrySloAlertType, number>();
499
+
500
+ constructor(options: TelemetryRegistryOptions) {
501
+ this.service = options.service;
502
+ this.module = options.module;
503
+ this.environment = options.environment;
504
+ this.samplingRate = clamp(options.samplingRate ?? 1, 0, 1);
505
+ this.maxBatchSize = Math.max(1, options.maxBatchSize ?? 50);
506
+ this.maxQueueSize = Math.max(1, options.maxQueueSize ?? 1000);
507
+ this.flushIntervalMs = Math.max(50, options.flushIntervalMs ?? 1000);
508
+ this.redactionKeys = new Set(options.redactionKeys || []);
509
+ this.queueUtilizationWarnThreshold = clamp(
510
+ options.slo?.queueUtilizationWarnThreshold ?? 0.8,
511
+ 0,
512
+ 1,
513
+ );
514
+ this.queueDroppedWarnThreshold = Math.max(
515
+ 1,
516
+ options.slo?.queueDroppedWarnThreshold ?? 1,
517
+ );
518
+ this.alertCooldownMs = Math.max(0, options.slo?.alertCooldownMs ?? 60_000);
519
+ this.onSloAlert = options.slo?.onAlert;
520
+
521
+ this.flushTimer = setInterval(() => {
522
+ void this.flush();
523
+ }, this.flushIntervalMs);
524
+ if (typeof (this.flushTimer as NodeJS.Timeout).unref === 'function') {
525
+ (this.flushTimer as NodeJS.Timeout).unref();
526
+ }
527
+ }
528
+
529
+ async register(exporter: TelemetryExporter) {
530
+ this.exporters.push(exporter);
531
+ this.exporterHealth.set(exporter.name, {
532
+ name: exporter.name,
533
+ healthy: true,
534
+ failures: 0,
535
+ });
536
+ if (exporter.init) {
537
+ try {
538
+ await exporter.init({
539
+ service: this.service,
540
+ module: this.module,
541
+ environment: this.environment,
542
+ });
543
+ this.markExporterHealthy(exporter.name);
544
+ } catch (error) {
545
+ this.markExporterFailure(exporter.name, error);
546
+ throw error;
547
+ }
548
+ } else {
549
+ this.markExporterHealthy(exporter.name);
550
+ }
551
+ }
552
+
553
+ private getOrCreateExporterHealth(name: string) {
554
+ const existing = this.exporterHealth.get(name);
555
+ if (existing) {
556
+ return existing;
557
+ }
558
+
559
+ const next: TelemetryExporterHealthStatus = {
560
+ name,
561
+ healthy: true,
562
+ failures: 0,
563
+ };
564
+ this.exporterHealth.set(name, next);
565
+ return next;
566
+ }
567
+
568
+ private markExporterHealthy(name: string) {
569
+ const status = this.getOrCreateExporterHealth(name);
570
+ status.healthy = true;
571
+ status.lastSuccessAt = Date.now();
572
+ status.lastError = undefined;
573
+ }
574
+
575
+ private markExporterFailure(name: string, error: unknown) {
576
+ const status = this.getOrCreateExporterHealth(name);
577
+ status.healthy = false;
578
+ status.failures += 1;
579
+ status.lastFailureAt = Date.now();
580
+ status.lastError = error instanceof Error ? error.message : String(error);
581
+ }
582
+
583
+ private maybeEmitSloAlert(
584
+ type: TelemetrySloAlertType,
585
+ value: number,
586
+ threshold: number,
587
+ ) {
588
+ if (!this.onSloAlert || value < threshold) {
589
+ return;
590
+ }
591
+
592
+ const now = Date.now();
593
+ const lastTimestamp = this.lastSloAlertAt.get(type) ?? 0;
594
+ if (now - lastTimestamp < this.alertCooldownMs) {
595
+ return;
596
+ }
597
+
598
+ this.lastSloAlertAt.set(type, now);
599
+ const queueDepth = this.queue.length;
600
+
601
+ try {
602
+ this.onSloAlert({
603
+ timestamp: now,
604
+ service: this.service,
605
+ module: this.module,
606
+ environment: this.environment,
607
+ type,
608
+ value,
609
+ threshold,
610
+ queueDepth,
611
+ queueCapacity: this.maxQueueSize,
612
+ queueUtilization: queueDepth / this.maxQueueSize,
613
+ totalDropped: this.totalDroppedCount,
614
+ });
615
+ } catch (_error) {
616
+ // SLO alert hooks must never crash telemetry pipeline.
617
+ }
618
+ }
619
+
620
+ enqueue(envelope: TelemetryEnvelope) {
621
+ if (this.samplingRate < 1 && Math.random() > this.samplingRate) {
622
+ return;
623
+ }
624
+
625
+ const redactedEnvelope =
626
+ this.redactionKeys.size > 0
627
+ ? ({
628
+ ...envelope,
629
+ attributes: redactObject(envelope.attributes, this.redactionKeys),
630
+ } as TelemetryEnvelope)
631
+ : envelope;
632
+
633
+ if (this.queue.length >= this.maxQueueSize) {
634
+ this.queue.shift();
635
+ this.droppedCount += 1;
636
+ this.totalDroppedCount += 1;
637
+ this.maybeEmitSloAlert(
638
+ 'queue.drop',
639
+ this.totalDroppedCount,
640
+ this.queueDroppedWarnThreshold,
641
+ );
642
+ }
643
+
644
+ this.queue.push(redactedEnvelope);
645
+ this.maybeEmitSloAlert(
646
+ 'queue.utilization',
647
+ this.queue.length / this.maxQueueSize,
648
+ this.queueUtilizationWarnThreshold,
649
+ );
650
+
651
+ if (this.queue.length >= this.maxBatchSize) {
652
+ void this.flush();
653
+ }
654
+ }
655
+
656
+ enqueueMetric(input: {
657
+ name: string;
658
+ value: number;
659
+ unit?: string;
660
+ traceId?: string;
661
+ spanId?: string;
662
+ parentSpanId?: string;
663
+ tags?: Record<string, string>;
664
+ attributes?: Record<string, unknown>;
665
+ }) {
666
+ this.enqueue({
667
+ timestamp: Date.now(),
668
+ service: this.service,
669
+ module: this.module,
670
+ environment: this.environment,
671
+ signalType: 'metric',
672
+ name: input.name,
673
+ value: input.value,
674
+ unit: input.unit || 'count',
675
+ traceId: input.traceId,
676
+ spanId: input.spanId,
677
+ parentSpanId: input.parentSpanId,
678
+ tags: input.tags,
679
+ attributes: input.attributes,
680
+ });
681
+ }
682
+
683
+ enqueueLog(input: {
684
+ name: string;
685
+ level: string;
686
+ traceId?: string;
687
+ spanId?: string;
688
+ parentSpanId?: string;
689
+ tags?: Record<string, string>;
690
+ attributes?: Record<string, unknown>;
691
+ error?: TelemetryEnvelope['error'];
692
+ }) {
693
+ this.enqueue({
694
+ timestamp: Date.now(),
695
+ service: this.service,
696
+ module: this.module,
697
+ environment: this.environment,
698
+ signalType: 'log',
699
+ name: input.name,
700
+ level: input.level,
701
+ traceId: input.traceId,
702
+ spanId: input.spanId,
703
+ parentSpanId: input.parentSpanId,
704
+ tags: input.tags,
705
+ attributes: input.attributes,
706
+ error: input.error,
707
+ });
708
+ }
709
+
710
+ enqueueTrace(input: {
711
+ name: string;
712
+ traceId?: string;
713
+ spanId?: string;
714
+ parentSpanId?: string;
715
+ tags?: Record<string, string>;
716
+ attributes?: Record<string, unknown>;
717
+ }) {
718
+ this.enqueue({
719
+ timestamp: Date.now(),
720
+ service: this.service,
721
+ module: this.module,
722
+ environment: this.environment,
723
+ signalType: 'trace',
724
+ name: input.name,
725
+ traceId: input.traceId,
726
+ spanId: input.spanId,
727
+ parentSpanId: input.parentSpanId,
728
+ tags: input.tags,
729
+ attributes: input.attributes,
730
+ });
731
+ }
732
+
733
+ private buildDroppedEnvelope(droppedCount: number): TelemetryEnvelope {
734
+ return {
735
+ timestamp: Date.now(),
736
+ service: this.service,
737
+ module: this.module,
738
+ environment: this.environment,
739
+ signalType: 'metric',
740
+ name: 'telemetry.queue.dropped',
741
+ value: droppedCount,
742
+ unit: 'count',
743
+ tags: {
744
+ reason: 'queue_backpressure',
745
+ },
746
+ };
747
+ }
748
+
749
+ private buildQueueDepthEnvelope(queueDepth: number): TelemetryEnvelope {
750
+ return {
751
+ timestamp: Date.now(),
752
+ service: this.service,
753
+ module: this.module,
754
+ environment: this.environment,
755
+ signalType: 'metric',
756
+ name: 'telemetry.queue.depth',
757
+ value: queueDepth,
758
+ unit: 'count',
759
+ tags: {
760
+ capacity: String(this.maxQueueSize),
761
+ },
762
+ };
763
+ }
764
+
765
+ private buildQueueUtilizationEnvelope(queueDepth: number): TelemetryEnvelope {
766
+ return {
767
+ timestamp: Date.now(),
768
+ service: this.service,
769
+ module: this.module,
770
+ environment: this.environment,
771
+ signalType: 'metric',
772
+ name: 'telemetry.queue.utilization',
773
+ value: queueDepth / this.maxQueueSize,
774
+ unit: 'ratio',
775
+ tags: {
776
+ capacity: String(this.maxQueueSize),
777
+ },
778
+ };
779
+ }
780
+
781
+ private async emitBatch(batch: TelemetryEnvelope[]) {
782
+ const results = await Promise.allSettled(
783
+ this.exporters.map(async exporter => {
784
+ await exporter.emit(batch);
785
+ return exporter.name;
786
+ }),
787
+ );
788
+
789
+ for (const [index, result] of results.entries()) {
790
+ const exporterName = this.exporters[index]?.name || `exporter-${index}`;
791
+ if (result.status === 'rejected') {
792
+ this.markExporterFailure(exporterName, result.reason);
793
+ continue;
794
+ }
795
+
796
+ this.markExporterHealthy(exporterName);
797
+ }
798
+ }
799
+
800
+ private buildStartupProbeEnvelope(): TelemetryEnvelope {
801
+ return {
802
+ timestamp: Date.now(),
803
+ service: this.service,
804
+ module: this.module,
805
+ environment: this.environment,
806
+ signalType: 'log',
807
+ name: 'telemetry.exporter.startup_probe',
808
+ level: 'info',
809
+ tags: {
810
+ phase: 'startup',
811
+ },
812
+ attributes: {
813
+ source: 'TelemetryRegistry',
814
+ },
815
+ };
816
+ }
817
+
818
+ async startupHealthCheck(options?: { failLoud?: boolean }) {
819
+ if (this.exporters.length === 0) {
820
+ return;
821
+ }
822
+
823
+ const probeBatch = [this.buildStartupProbeEnvelope()];
824
+ const failedExporters: TelemetryExporterHealthStatus[] = [];
825
+
826
+ await Promise.all(
827
+ this.exporters.map(async exporter => {
828
+ try {
829
+ await exporter.emit(probeBatch);
830
+ this.markExporterHealthy(exporter.name);
831
+ } catch (error) {
832
+ this.markExporterFailure(exporter.name, error);
833
+ const status = this.exporterHealth.get(exporter.name);
834
+ if (status) {
835
+ failedExporters.push({ ...status });
836
+ }
837
+ }
838
+ }),
839
+ );
840
+
841
+ if ((options?.failLoud ?? true) && failedExporters.length > 0) {
842
+ throw new TelemetryStartupHealthError(failedExporters);
843
+ }
844
+ }
845
+
846
+ getExporterHealth(): TelemetryExporterHealthStatus[] {
847
+ return Array.from(this.exporterHealth.values()).map(item => ({
848
+ ...item,
849
+ }));
850
+ }
851
+
852
+ getQueueStats(): TelemetryQueueStats {
853
+ return {
854
+ depth: this.queue.length,
855
+ capacity: this.maxQueueSize,
856
+ utilization: this.queue.length / this.maxQueueSize,
857
+ pendingDropped: this.droppedCount,
858
+ totalDropped: this.totalDroppedCount,
859
+ };
860
+ }
861
+
862
+ private async flushInternal() {
863
+ const queueDepthBeforeFlush = this.queue.length;
864
+ if (queueDepthBeforeFlush > 0) {
865
+ this.queue.unshift(
866
+ this.buildQueueUtilizationEnvelope(queueDepthBeforeFlush),
867
+ );
868
+ this.queue.unshift(this.buildQueueDepthEnvelope(queueDepthBeforeFlush));
869
+ }
870
+
871
+ if (this.droppedCount > 0) {
872
+ const droppedCount = this.droppedCount;
873
+ this.droppedCount = 0;
874
+ this.queue.unshift(this.buildDroppedEnvelope(droppedCount));
875
+ }
876
+
877
+ if (this.queue.length === 0) {
878
+ return;
879
+ }
880
+
881
+ if (this.exporters.length === 0) {
882
+ this.queue.length = 0;
883
+ return;
884
+ }
885
+
886
+ while (this.queue.length > 0) {
887
+ const batch = this.queue.splice(0, this.maxBatchSize);
888
+ await this.emitBatch(batch);
889
+ }
890
+
891
+ await Promise.allSettled(
892
+ this.exporters.map(async exporter => {
893
+ if (exporter.flush) {
894
+ await exporter.flush();
895
+ }
896
+ }),
897
+ );
898
+ }
899
+
900
+ flush() {
901
+ if (this.flushing) {
902
+ return this.flushing;
903
+ }
904
+
905
+ this.flushing = this.flushInternal().finally(() => {
906
+ this.flushing = null;
907
+ });
908
+
909
+ return this.flushing;
910
+ }
911
+
912
+ async shutdown() {
913
+ if (this.flushTimer) {
914
+ clearInterval(this.flushTimer);
915
+ }
916
+
917
+ await this.flush();
918
+ await Promise.allSettled(
919
+ this.exporters.map(async exporter => {
920
+ if (exporter.shutdown) {
921
+ await exporter.shutdown();
922
+ }
923
+ }),
924
+ );
925
+ }
926
+ }
927
+
928
+ export class TelemetryCanaryOrchestrator {
929
+ private readonly registry: TelemetryRegistry;
930
+ private readonly evaluationIntervalMs: number;
931
+ private readonly minConsecutiveHealthyEvaluations: number;
932
+ private readonly rollbackConsecutiveFailures: number;
933
+ private readonly maxQueueUtilization: number;
934
+ private readonly maxTotalDropped: number;
935
+ private readonly maxUnhealthyExporters: number;
936
+ private requiredContractGates: string[] = [];
937
+ private readonly onEvaluate?: (decision: TelemetryCanaryDecision) => void;
938
+ private readonly onPromote?: (decision: TelemetryCanaryDecision) => void;
939
+ private readonly onRollback?: (decision: TelemetryCanaryDecision) => void;
940
+ private readonly contractGates = new Map<
941
+ string,
942
+ TelemetryCanaryContractGateStatus
943
+ >();
944
+ private state: TelemetryCanaryState = 'canary';
945
+ private consecutiveHealthy = 0;
946
+ private consecutiveFailures = 0;
947
+ private evaluationTimer?: ReturnType<typeof setInterval>;
948
+
949
+ constructor(options: TelemetryCanaryOrchestratorOptions) {
950
+ this.registry = options.registry;
951
+ this.evaluationIntervalMs = Math.max(
952
+ 250,
953
+ options.evaluationIntervalMs ?? 15_000,
954
+ );
955
+ this.minConsecutiveHealthyEvaluations = Math.max(
956
+ 1,
957
+ options.minConsecutiveHealthyEvaluations ?? 3,
958
+ );
959
+ this.rollbackConsecutiveFailures = Math.max(
960
+ 1,
961
+ options.rollbackConsecutiveFailures ?? 2,
962
+ );
963
+ this.maxQueueUtilization = clamp(options.maxQueueUtilization ?? 0.8, 0, 1);
964
+ this.maxTotalDropped = Math.max(0, options.maxTotalDropped ?? 0);
965
+ this.maxUnhealthyExporters = Math.max(
966
+ 0,
967
+ options.maxUnhealthyExporters ?? 0,
968
+ );
969
+ this.setRequiredContractGates(options.requiredContractGates || []);
970
+ this.onEvaluate = options.onEvaluate;
971
+ this.onPromote = options.onPromote;
972
+ this.onRollback = options.onRollback;
973
+ }
974
+
975
+ setRequiredContractGates(gates: string[]) {
976
+ this.requiredContractGates = Array.from(
977
+ new Set(gates.map(item => item.trim()).filter(Boolean)),
978
+ );
979
+ }
980
+
981
+ addRequiredContractGate(name: string) {
982
+ const normalizedName = name.trim();
983
+ if (!normalizedName) {
984
+ return;
985
+ }
986
+
987
+ if (!this.requiredContractGates.includes(normalizedName)) {
988
+ this.requiredContractGates.push(normalizedName);
989
+ }
990
+ }
991
+
992
+ setContractGate(name: string, passed: boolean, reason?: string) {
993
+ this.contractGates.set(name, {
994
+ name,
995
+ passed,
996
+ reason,
997
+ updatedAt: Date.now(),
998
+ });
999
+ }
1000
+
1001
+ setContractGates(
1002
+ gates: Record<string, boolean | { passed: boolean; reason?: string }>,
1003
+ ) {
1004
+ for (const [name, value] of Object.entries(gates)) {
1005
+ if (typeof value === 'boolean') {
1006
+ this.setContractGate(name, value);
1007
+ continue;
1008
+ }
1009
+
1010
+ this.setContractGate(name, value.passed, value.reason);
1011
+ }
1012
+ }
1013
+
1014
+ resetToCanary() {
1015
+ this.state = 'canary';
1016
+ this.consecutiveHealthy = 0;
1017
+ this.consecutiveFailures = 0;
1018
+ }
1019
+
1020
+ private collectFailures(): {
1021
+ failures: TelemetryCanaryFailure[];
1022
+ queueStats: TelemetryQueueStats;
1023
+ unhealthyExporterCount: number;
1024
+ } {
1025
+ const failures: TelemetryCanaryFailure[] = [];
1026
+ const queueStats = this.registry.getQueueStats();
1027
+ const unhealthyExporterCount = this.registry
1028
+ .getExporterHealth()
1029
+ .filter(item => !item.healthy).length;
1030
+
1031
+ if (queueStats.utilization > this.maxQueueUtilization) {
1032
+ failures.push({
1033
+ reason: 'queue_utilization',
1034
+ threshold: this.maxQueueUtilization,
1035
+ value: queueStats.utilization,
1036
+ });
1037
+ }
1038
+
1039
+ if (queueStats.totalDropped > this.maxTotalDropped) {
1040
+ failures.push({
1041
+ reason: 'queue_dropped',
1042
+ threshold: this.maxTotalDropped,
1043
+ value: queueStats.totalDropped,
1044
+ });
1045
+ }
1046
+
1047
+ if (unhealthyExporterCount > this.maxUnhealthyExporters) {
1048
+ failures.push({
1049
+ reason: 'unhealthy_exporter',
1050
+ threshold: this.maxUnhealthyExporters,
1051
+ value: unhealthyExporterCount,
1052
+ });
1053
+ }
1054
+
1055
+ for (const gateName of this.requiredContractGates) {
1056
+ const gate = this.contractGates.get(gateName);
1057
+ if (!gate) {
1058
+ failures.push({
1059
+ reason: 'contract_gate_missing',
1060
+ gate: gateName,
1061
+ message: `Contract gate "${gateName}" is missing`,
1062
+ });
1063
+ continue;
1064
+ }
1065
+
1066
+ if (!gate.passed) {
1067
+ failures.push({
1068
+ reason: 'contract_gate_failed',
1069
+ gate: gateName,
1070
+ message: gate.reason || `Contract gate "${gateName}" is not passing`,
1071
+ });
1072
+ }
1073
+ }
1074
+
1075
+ return {
1076
+ failures,
1077
+ queueStats,
1078
+ unhealthyExporterCount,
1079
+ };
1080
+ }
1081
+
1082
+ evaluate(): TelemetryCanaryDecision {
1083
+ const now = Date.now();
1084
+ const { failures, queueStats, unhealthyExporterCount } =
1085
+ this.collectFailures();
1086
+ let action: TelemetryCanaryAction = 'hold';
1087
+
1088
+ if (failures.length > 0) {
1089
+ this.consecutiveHealthy = 0;
1090
+ this.consecutiveFailures += 1;
1091
+
1092
+ if (
1093
+ this.state !== 'rolled_back' &&
1094
+ this.consecutiveFailures >= this.rollbackConsecutiveFailures
1095
+ ) {
1096
+ this.state = 'rolled_back';
1097
+ action = 'rollback';
1098
+ }
1099
+ } else {
1100
+ this.consecutiveFailures = 0;
1101
+ this.consecutiveHealthy += 1;
1102
+ if (
1103
+ this.state === 'canary' &&
1104
+ this.consecutiveHealthy >= this.minConsecutiveHealthyEvaluations
1105
+ ) {
1106
+ this.state = 'promoted';
1107
+ action = 'promote';
1108
+ }
1109
+ }
1110
+
1111
+ const decision: TelemetryCanaryDecision = {
1112
+ timestamp: now,
1113
+ action,
1114
+ state: this.state,
1115
+ consecutiveHealthy: this.consecutiveHealthy,
1116
+ consecutiveFailures: this.consecutiveFailures,
1117
+ failures,
1118
+ queueStats,
1119
+ unhealthyExporterCount,
1120
+ contractGates: Array.from(this.contractGates.values()).map(item => ({
1121
+ ...item,
1122
+ })),
1123
+ };
1124
+
1125
+ try {
1126
+ this.onEvaluate?.(decision);
1127
+ } catch (_error) {
1128
+ // canary observer hooks must never crash server.
1129
+ }
1130
+
1131
+ if (action === 'promote') {
1132
+ try {
1133
+ this.onPromote?.(decision);
1134
+ } catch (_error) {
1135
+ // canary observer hooks must never crash server.
1136
+ }
1137
+ }
1138
+
1139
+ if (action === 'rollback') {
1140
+ try {
1141
+ this.onRollback?.(decision);
1142
+ } catch (_error) {
1143
+ // canary observer hooks must never crash server.
1144
+ }
1145
+ }
1146
+
1147
+ return decision;
1148
+ }
1149
+
1150
+ getStatusSnapshot(): TelemetryCanaryStatusSnapshot {
1151
+ const now = Date.now();
1152
+ const { failures, queueStats, unhealthyExporterCount } =
1153
+ this.collectFailures();
1154
+ return {
1155
+ timestamp: now,
1156
+ state: this.state,
1157
+ consecutiveHealthy: this.consecutiveHealthy,
1158
+ consecutiveFailures: this.consecutiveFailures,
1159
+ queueStats,
1160
+ unhealthyExporterCount,
1161
+ requiredContractGates: [...this.requiredContractGates],
1162
+ contractGates: Array.from(this.contractGates.values()).map(item => ({
1163
+ ...item,
1164
+ })),
1165
+ failurePreview: failures,
1166
+ };
1167
+ }
1168
+
1169
+ start() {
1170
+ if (this.evaluationTimer) {
1171
+ return;
1172
+ }
1173
+ this.evaluationTimer = setInterval(() => {
1174
+ this.evaluate();
1175
+ }, this.evaluationIntervalMs);
1176
+ if (typeof (this.evaluationTimer as NodeJS.Timeout).unref === 'function') {
1177
+ (this.evaluationTimer as NodeJS.Timeout).unref();
1178
+ }
1179
+ }
1180
+
1181
+ stop() {
1182
+ if (this.evaluationTimer) {
1183
+ clearInterval(this.evaluationTimer);
1184
+ this.evaluationTimer = undefined;
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ function normalizeMetricsInput(
1190
+ prefixOrTags?: TelemetryMetricsPrefixOrTags,
1191
+ tags?: TelemetryMetricsTags,
1192
+ ) {
1193
+ if (typeof prefixOrTags === 'string') {
1194
+ return {
1195
+ prefix: prefixOrTags,
1196
+ tags: tags || {},
1197
+ };
1198
+ }
1199
+
1200
+ if (isRecord(prefixOrTags)) {
1201
+ return {
1202
+ prefix: undefined,
1203
+ tags: prefixOrTags,
1204
+ };
1205
+ }
1206
+
1207
+ return {
1208
+ prefix: undefined,
1209
+ tags: tags || {},
1210
+ };
1211
+ }
1212
+
1213
+ function normalizeMetricName(name: string, prefix: string | undefined) {
1214
+ return prefix && prefix.length > 0 ? `${prefix}.${name}` : name;
1215
+ }
1216
+
1217
+ function toTelemetryMetricTags(tags: TelemetryMetricsTags) {
1218
+ const output: Record<string, string> = {};
1219
+ for (const [key, value] of Object.entries(tags)) {
1220
+ if (value === undefined || value === null) {
1221
+ continue;
1222
+ }
1223
+ output[key] = String(value);
1224
+ }
1225
+ return output;
1226
+ }
1227
+
1228
+ function getTraceContext(tags: TelemetryMetricsTags) {
1229
+ const traceId =
1230
+ typeof tags.trace_id === 'string'
1231
+ ? tags.trace_id
1232
+ : typeof tags.traceId === 'string'
1233
+ ? tags.traceId
1234
+ : undefined;
1235
+
1236
+ const spanId =
1237
+ typeof tags.span_id === 'string'
1238
+ ? tags.span_id
1239
+ : typeof tags.spanId === 'string'
1240
+ ? tags.spanId
1241
+ : undefined;
1242
+
1243
+ const parentSpanId =
1244
+ typeof tags.parent_span_id === 'string'
1245
+ ? tags.parent_span_id
1246
+ : typeof tags.parentSpanId === 'string'
1247
+ ? tags.parentSpanId
1248
+ : undefined;
1249
+
1250
+ return {
1251
+ traceId,
1252
+ spanId,
1253
+ parentSpanId,
1254
+ };
1255
+ }
1256
+
1257
+ export const createTelemetryAwareMetrics = <T extends Metrics>(
1258
+ baseMetrics: T,
1259
+ registry: TelemetryRegistry,
1260
+ ): T => {
1261
+ const emitCounter: Metrics['emitCounter'] = (
1262
+ name,
1263
+ value,
1264
+ prefixOrTags,
1265
+ tags,
1266
+ ) => {
1267
+ const normalized = normalizeMetricsInput(
1268
+ prefixOrTags as TelemetryMetricsPrefixOrTags | undefined,
1269
+ tags,
1270
+ );
1271
+ baseMetrics.emitCounter(name, value, normalized.prefix, normalized.tags);
1272
+
1273
+ try {
1274
+ const metricName = normalizeMetricName(name, normalized.prefix);
1275
+ const traceContext = getTraceContext(normalized.tags);
1276
+ registry.enqueueMetric({
1277
+ name: metricName,
1278
+ value,
1279
+ unit: 'count',
1280
+ traceId: traceContext.traceId,
1281
+ spanId: traceContext.spanId,
1282
+ parentSpanId: traceContext.parentSpanId,
1283
+ tags: toTelemetryMetricTags(normalized.tags),
1284
+ attributes: normalized.tags,
1285
+ });
1286
+ } catch (_error) {
1287
+ // telemetry wrapping must never break request metrics.
1288
+ }
1289
+ };
1290
+
1291
+ const emitTimer: Metrics['emitTimer'] = (name, value, prefixOrTags, tags) => {
1292
+ const normalized = normalizeMetricsInput(
1293
+ prefixOrTags as TelemetryMetricsPrefixOrTags | undefined,
1294
+ tags,
1295
+ );
1296
+ baseMetrics.emitTimer(name, value, normalized.prefix, normalized.tags);
1297
+
1298
+ try {
1299
+ const metricName = normalizeMetricName(name, normalized.prefix);
1300
+ const traceContext = getTraceContext(normalized.tags);
1301
+ registry.enqueueMetric({
1302
+ name: metricName,
1303
+ value,
1304
+ unit: 'ms',
1305
+ traceId: traceContext.traceId,
1306
+ spanId: traceContext.spanId,
1307
+ parentSpanId: traceContext.parentSpanId,
1308
+ tags: toTelemetryMetricTags(normalized.tags),
1309
+ attributes: normalized.tags,
1310
+ });
1311
+ } catch (_error) {
1312
+ // telemetry wrapping must never break request metrics.
1313
+ }
1314
+ };
1315
+
1316
+ return {
1317
+ ...baseMetrics,
1318
+ emitCounter,
1319
+ emitTimer,
1320
+ };
1321
+ };
1322
+
1323
+ export function maybeWarnLegacyOtlpEndpoint(endpoint: string | undefined) {
1324
+ if (!endpoint || !endpoint.includes('/v1/metrics')) {
1325
+ return;
1326
+ }
1327
+ // Keep this warning lightweight and runtime-safe.
1328
+ // eslint-disable-next-line no-console
1329
+ console.warn(
1330
+ `[telemetry] OTLP endpoint "${endpoint}" looks like a metrics path. UltraModern telemetry exporter expects log-style envelopes (default: ${DEFAULT_OTLP_ENDPOINT}).`,
1331
+ );
1332
+ }