@chidchanun/bcp 0.2.16 → 0.2.17

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,878 @@
1
+ import {
2
+ AsyncLocalStorage,
3
+ } from "node:async_hooks";
4
+ import {
5
+ randomBytes,
6
+ randomUUID,
7
+ } from "node:crypto";
8
+
9
+ import type {
10
+ MiddlewareContext,
11
+ MiddlewareNext,
12
+ MiddlewarePipelineHandler,
13
+ MiddlewareRequest,
14
+ } from "./middleware.js";
15
+
16
+ export type TraceAttributeValue =
17
+ | string
18
+ | number
19
+ | boolean;
20
+
21
+ export type TraceAttributes =
22
+ Readonly<Record<string, TraceAttributeValue>>;
23
+
24
+ export type TraceSpanKind =
25
+ | "internal"
26
+ | "server"
27
+ | "client"
28
+ | "producer"
29
+ | "consumer";
30
+
31
+ export type TraceSpanStatus =
32
+ | "unset"
33
+ | "ok"
34
+ | "error";
35
+
36
+ export interface TraceContext {
37
+ traceId: string;
38
+ spanId: string;
39
+ traceFlags: string;
40
+ correlationId: string;
41
+ tracestate?: string;
42
+ }
43
+
44
+ export interface TraceCarrier {
45
+ traceparent: string;
46
+ correlationId: string;
47
+ tracestate?: string;
48
+ }
49
+
50
+ export interface TraceSpanEvent {
51
+ name: string;
52
+ timestamp: number;
53
+ attributes: Record<string, TraceAttributeValue>;
54
+ }
55
+
56
+ export interface TraceSpanRecord {
57
+ name: string;
58
+ kind: TraceSpanKind;
59
+ traceId: string;
60
+ spanId: string;
61
+ parentSpanId?: string;
62
+ traceFlags: string;
63
+ correlationId: string;
64
+ tracestate?: string;
65
+ serviceName?: string;
66
+ startedAt: number;
67
+ endedAt: number;
68
+ durationMs: number;
69
+ status: TraceSpanStatus;
70
+ statusMessage?: string;
71
+ attributes: Record<string, TraceAttributeValue>;
72
+ events: TraceSpanEvent[];
73
+ }
74
+
75
+ export interface TraceSpanExporter {
76
+ export(span: TraceSpanRecord): void | Promise<void>;
77
+ shutdown?(): void | Promise<void>;
78
+ }
79
+
80
+ export interface MemoryTraceSpanExporter
81
+ extends TraceSpanExporter {
82
+ spans(): TraceSpanRecord[];
83
+ clear(): void;
84
+ }
85
+
86
+ export interface TraceIdFactory {
87
+ traceId(): string;
88
+ spanId(): string;
89
+ correlationId(): string;
90
+ }
91
+
92
+ export interface StartTraceSpanOptions {
93
+ parent?: TraceContext | null;
94
+ kind?: TraceSpanKind;
95
+ attributes?: TraceAttributes;
96
+ startTime?: number;
97
+ }
98
+
99
+ export interface TraceSpan {
100
+ readonly name: string;
101
+ readonly context: TraceContext;
102
+ readonly parentSpanId: string | undefined;
103
+ readonly kind: TraceSpanKind;
104
+ readonly status: TraceSpanStatus;
105
+ readonly ended: boolean;
106
+ setAttribute(name: string, value: TraceAttributeValue): void;
107
+ addEvent(name: string, attributes?: TraceAttributes): void;
108
+ setStatus(status: TraceSpanStatus, message?: string): void;
109
+ end(endTime?: number): Promise<TraceSpanRecord>;
110
+ }
111
+
112
+ export interface TracerOptions {
113
+ exporter?: TraceSpanExporter;
114
+ serviceName?: string;
115
+ now?: () => number;
116
+ idFactory?: TraceIdFactory;
117
+ defaultAttributes?: TraceAttributes;
118
+ }
119
+
120
+ export interface Tracer {
121
+ startSpan(name: string, options?: StartTraceSpanOptions): TraceSpan;
122
+ withSpan<T>(
123
+ name: string,
124
+ callback: (span: TraceSpan) => T | Promise<T>,
125
+ options?: StartTraceSpanOptions
126
+ ): Promise<T>;
127
+ currentContext(): TraceContext | undefined;
128
+ runWithContext<T>(context: TraceContext, callback: () => T): T;
129
+ shutdown(): Promise<void>;
130
+ }
131
+
132
+ export interface RequestTracingOptions {
133
+ spanName?: string | ((request: MiddlewareRequest) => string);
134
+ includeResponseHeaders?: boolean;
135
+ attributes?:
136
+ | TraceAttributes
137
+ | ((request: MiddlewareRequest) => TraceAttributes);
138
+ }
139
+
140
+ export interface TraceMetricsRegistryLike {
141
+ counter(
142
+ name: string,
143
+ options?: {
144
+ help?: string;
145
+ labelNames?: readonly string[];
146
+ }
147
+ ): {
148
+ inc(value?: number, labels?: TraceAttributes): void;
149
+ };
150
+ histogram(
151
+ name: string,
152
+ options?: {
153
+ help?: string;
154
+ labelNames?: readonly string[];
155
+ buckets?: readonly number[];
156
+ }
157
+ ): {
158
+ observe(value: number, labels?: TraceAttributes): void;
159
+ };
160
+ }
161
+
162
+ export interface TraceMetricsOptions {
163
+ prefix?: string;
164
+ includeSpanName?: boolean;
165
+ }
166
+
167
+ const TRACEPARENT_PATTERN =
168
+ /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
169
+
170
+ const traceStorage =
171
+ new AsyncLocalStorage<TraceContext>();
172
+
173
+ export function createTracer(
174
+ options: TracerOptions = {}
175
+ ): Tracer {
176
+ const exporter = options.exporter;
177
+ const now = options.now ?? Date.now;
178
+ const idFactory =
179
+ options.idFactory ?? createDefaultTraceIdFactory();
180
+ const serviceName =
181
+ normalizeOptionalText(options.serviceName);
182
+ const defaultAttributes =
183
+ normalizeAttributes(options.defaultAttributes);
184
+
185
+ function startSpan(
186
+ rawName: string,
187
+ spanOptions: StartTraceSpanOptions = {}
188
+ ): TraceSpan {
189
+ const name = normalizeName(rawName, "span name");
190
+ const parent =
191
+ spanOptions.parent === undefined
192
+ ? currentTraceContext()
193
+ : spanOptions.parent ?? undefined;
194
+ const traceId = parent?.traceId ??
195
+ normalizeTraceId(idFactory.traceId());
196
+ const spanId =
197
+ normalizeSpanId(idFactory.spanId());
198
+ const traceFlags = parent?.traceFlags ?? "01";
199
+ const correlationId = parent?.correlationId ??
200
+ normalizeCorrelationId(idFactory.correlationId());
201
+ const tracestate = parent?.tracestate;
202
+ const context: TraceContext = {
203
+ traceId,
204
+ spanId,
205
+ traceFlags,
206
+ correlationId,
207
+ ...(tracestate ? { tracestate } : {}),
208
+ };
209
+ const attributes = {
210
+ ...defaultAttributes,
211
+ ...normalizeAttributes(spanOptions.attributes),
212
+ };
213
+ const events: TraceSpanEvent[] = [];
214
+ const startedAt = spanOptions.startTime ?? now();
215
+ assertTimestamp(startedAt, "span startTime");
216
+ const kind = spanOptions.kind ?? "internal";
217
+ let status: TraceSpanStatus = "unset";
218
+ let statusMessage: string | undefined;
219
+ let ended = false;
220
+ let completed: TraceSpanRecord | undefined;
221
+
222
+ const span: TraceSpan = {
223
+ name,
224
+ context,
225
+ parentSpanId: parent?.spanId,
226
+ kind,
227
+ get status(): TraceSpanStatus {
228
+ return status;
229
+ },
230
+ get ended(): boolean {
231
+ return ended;
232
+ },
233
+ setAttribute(
234
+ rawAttributeName: string,
235
+ value: TraceAttributeValue
236
+ ): void {
237
+ assertNotEnded();
238
+ attributes[
239
+ normalizeName(rawAttributeName, "attribute name")
240
+ ] = normalizeAttributeValue(value);
241
+ },
242
+ addEvent(
243
+ rawEventName: string,
244
+ eventAttributes: TraceAttributes = {}
245
+ ): void {
246
+ assertNotEnded();
247
+ const timestamp = now();
248
+ assertTimestamp(timestamp, "event timestamp");
249
+ events.push({
250
+ name: normalizeName(rawEventName, "event name"),
251
+ timestamp,
252
+ attributes: normalizeAttributes(eventAttributes),
253
+ });
254
+ },
255
+ setStatus(
256
+ nextStatus: TraceSpanStatus,
257
+ message?: string
258
+ ): void {
259
+ assertNotEnded();
260
+ status = normalizeStatus(nextStatus);
261
+ statusMessage = normalizeOptionalText(message);
262
+ },
263
+ async end(endTime?: number): Promise<TraceSpanRecord> {
264
+ if (completed) {
265
+ return cloneTraceSpanRecord(completed);
266
+ }
267
+ const endedAt = endTime ?? now();
268
+ assertTimestamp(endedAt, "span endTime");
269
+ if (endedAt < startedAt) {
270
+ throw new RangeError(
271
+ "BCP Observability: span endTime cannot be earlier than startTime."
272
+ );
273
+ }
274
+ ended = true;
275
+ completed = {
276
+ name,
277
+ kind,
278
+ traceId,
279
+ spanId,
280
+ ...(parent?.spanId
281
+ ? { parentSpanId: parent.spanId }
282
+ : {}),
283
+ traceFlags,
284
+ correlationId,
285
+ ...(tracestate ? { tracestate } : {}),
286
+ ...(serviceName ? { serviceName } : {}),
287
+ startedAt,
288
+ endedAt,
289
+ durationMs: endedAt - startedAt,
290
+ status,
291
+ ...(statusMessage ? { statusMessage } : {}),
292
+ attributes: { ...attributes },
293
+ events: events.map(cloneTraceEvent),
294
+ };
295
+ try {
296
+ await exporter?.export(
297
+ cloneTraceSpanRecord(completed)
298
+ );
299
+ } catch {
300
+ // Exporters must not break application execution.
301
+ }
302
+ return cloneTraceSpanRecord(completed);
303
+ },
304
+ };
305
+
306
+ return span;
307
+
308
+ function assertNotEnded(): void {
309
+ if (ended) {
310
+ throw new Error(
311
+ `BCP Observability: span "${name}" has already ended.`
312
+ );
313
+ }
314
+ }
315
+ }
316
+
317
+ async function withSpan<T>(
318
+ name: string,
319
+ callback: (span: TraceSpan) => T | Promise<T>,
320
+ spanOptions: StartTraceSpanOptions = {}
321
+ ): Promise<T> {
322
+ if (typeof callback !== "function") {
323
+ throw new TypeError(
324
+ "BCP Observability: span callback must be a function."
325
+ );
326
+ }
327
+ const span = startSpan(name, spanOptions);
328
+ return await traceStorage.run(
329
+ span.context,
330
+ async (): Promise<T> => {
331
+ try {
332
+ const value = await callback(span);
333
+ if (
334
+ !span.ended &&
335
+ span.status === "unset"
336
+ ) {
337
+ span.setStatus("ok");
338
+ }
339
+ return value;
340
+ } catch (error) {
341
+ if (!span.ended) {
342
+ span.setStatus(
343
+ "error",
344
+ error instanceof Error
345
+ ? error.message
346
+ : "Span callback failed."
347
+ );
348
+ span.addEvent("exception", {
349
+ "exception.type":
350
+ error instanceof Error
351
+ ? error.name
352
+ : typeof error,
353
+ "exception.message":
354
+ error instanceof Error
355
+ ? error.message
356
+ : String(error),
357
+ });
358
+ }
359
+ throw error;
360
+ } finally {
361
+ if (!span.ended) {
362
+ await span.end();
363
+ }
364
+ }
365
+ }
366
+ );
367
+ }
368
+
369
+ const tracer: Tracer = {
370
+ startSpan,
371
+ withSpan,
372
+ currentContext(): TraceContext | undefined {
373
+ return currentTraceContext();
374
+ },
375
+ runWithContext<T>(
376
+ context: TraceContext,
377
+ callback: () => T
378
+ ): T {
379
+ return runWithTraceContext(context, callback);
380
+ },
381
+ async shutdown(): Promise<void> {
382
+ await exporter?.shutdown?.();
383
+ },
384
+ };
385
+
386
+ return tracer;
387
+ }
388
+
389
+ export function currentTraceContext(): TraceContext | undefined {
390
+ const current = traceStorage.getStore();
391
+ return current ? cloneTraceContext(current) : undefined;
392
+ }
393
+
394
+ export function runWithTraceContext<T>(
395
+ context: TraceContext,
396
+ callback: () => T
397
+ ): T {
398
+ if (typeof callback !== "function") {
399
+ throw new TypeError(
400
+ "BCP Observability: trace context callback must be a function."
401
+ );
402
+ }
403
+ return traceStorage.run(
404
+ normalizeTraceContext(context),
405
+ callback
406
+ );
407
+ }
408
+
409
+ export function createTraceCarrier(
410
+ context: TraceContext | undefined = currentTraceContext()
411
+ ): TraceCarrier | undefined {
412
+ if (!context) {
413
+ return undefined;
414
+ }
415
+ const normalized = normalizeTraceContext(context);
416
+ return {
417
+ traceparent: formatTraceparent(normalized),
418
+ correlationId: normalized.correlationId,
419
+ ...(normalized.tracestate
420
+ ? { tracestate: normalized.tracestate }
421
+ : {}),
422
+ };
423
+ }
424
+
425
+ export function runWithTraceCarrier<T>(
426
+ carrier: TraceCarrier | undefined | null,
427
+ callback: () => T
428
+ ): T {
429
+ if (!carrier) {
430
+ return callback();
431
+ }
432
+ const context = extractTraceCarrier(carrier);
433
+ return context
434
+ ? runWithTraceContext(context, callback)
435
+ : callback();
436
+ }
437
+
438
+ export function extractTraceCarrier(
439
+ carrier:
440
+ | TraceCarrier
441
+ | Readonly<Record<string, string | undefined>>
442
+ ): TraceContext | null {
443
+ const traceparent = carrier.traceparent;
444
+ if (!traceparent) {
445
+ return null;
446
+ }
447
+ const parsed = parseTraceparent(traceparent);
448
+ if (!parsed) {
449
+ return null;
450
+ }
451
+ const correlationId =
452
+ typeof carrier.correlationId === "string"
453
+ ? carrier.correlationId
454
+ : parsed.traceId;
455
+ const tracestate =
456
+ typeof carrier.tracestate === "string"
457
+ ? normalizeOptionalText(carrier.tracestate)
458
+ : undefined;
459
+ return {
460
+ ...parsed,
461
+ correlationId: normalizeCorrelationId(correlationId),
462
+ ...(tracestate ? { tracestate } : {}),
463
+ };
464
+ }
465
+
466
+ export function injectTraceHeaders(
467
+ headers: Headers,
468
+ context: TraceContext | undefined = currentTraceContext()
469
+ ): Headers {
470
+ if (!(headers instanceof Headers)) {
471
+ throw new TypeError(
472
+ "BCP Observability: injectTraceHeaders() requires Headers."
473
+ );
474
+ }
475
+ if (!context) {
476
+ return headers;
477
+ }
478
+ const normalized = normalizeTraceContext(context);
479
+ headers.set("traceparent", formatTraceparent(normalized));
480
+ headers.set("x-correlation-id", normalized.correlationId);
481
+ if (normalized.tracestate) {
482
+ headers.set("tracestate", normalized.tracestate);
483
+ } else {
484
+ headers.delete("tracestate");
485
+ }
486
+ return headers;
487
+ }
488
+
489
+ export function extractTraceHeaders(
490
+ input: Headers | { readonly headers: Headers }
491
+ ): TraceContext | null {
492
+ const headers =
493
+ input instanceof Headers
494
+ ? input
495
+ : input.headers;
496
+ if (!(headers instanceof Headers)) {
497
+ throw new TypeError(
498
+ "BCP Observability: extractTraceHeaders() requires Headers or a request-like object."
499
+ );
500
+ }
501
+ const parsed = parseTraceparent(headers.get("traceparent"));
502
+ if (!parsed) {
503
+ return null;
504
+ }
505
+ const correlationId =
506
+ headers.get("x-correlation-id") ?? parsed.traceId;
507
+ const tracestate = normalizeOptionalText(
508
+ headers.get("tracestate") ?? undefined
509
+ );
510
+ return {
511
+ ...parsed,
512
+ correlationId: normalizeCorrelationId(correlationId),
513
+ ...(tracestate ? { tracestate } : {}),
514
+ };
515
+ }
516
+
517
+ export function formatTraceparent(
518
+ context: Pick<TraceContext, "traceId" | "spanId" | "traceFlags">
519
+ ): string {
520
+ return [
521
+ "00",
522
+ normalizeTraceId(context.traceId),
523
+ normalizeSpanId(context.spanId),
524
+ normalizeTraceFlags(context.traceFlags),
525
+ ].join("-");
526
+ }
527
+
528
+ export function parseTraceparent(
529
+ value: string | null | undefined
530
+ ): Pick<TraceContext, "traceId" | "spanId" | "traceFlags"> | null {
531
+ if (!value) {
532
+ return null;
533
+ }
534
+ const match = TRACEPARENT_PATTERN.exec(
535
+ value.trim().toLowerCase()
536
+ );
537
+ if (!match || /^0+$/.test(match[1]) || /^0+$/.test(match[2])) {
538
+ return null;
539
+ }
540
+ return {
541
+ traceId: match[1],
542
+ spanId: match[2],
543
+ traceFlags: match[3],
544
+ };
545
+ }
546
+
547
+ export function createRequestTracingMiddleware(
548
+ tracer: Tracer,
549
+ options: RequestTracingOptions = {}
550
+ ): MiddlewarePipelineHandler {
551
+ if (!tracer) {
552
+ throw new TypeError(
553
+ "BCP Observability: request tracing requires a tracer."
554
+ );
555
+ }
556
+
557
+ const middleware: MiddlewarePipelineHandler = async (
558
+ request: MiddlewareRequest,
559
+ next: MiddlewareNext,
560
+ _context: MiddlewareContext
561
+ ): Promise<Response> => {
562
+ const incoming = extractTraceHeaders(request.headers);
563
+ const spanName =
564
+ typeof options.spanName === "function"
565
+ ? options.spanName(request)
566
+ : options.spanName ??
567
+ `HTTP ${request.method.toUpperCase()}`;
568
+ const optionAttributes =
569
+ typeof options.attributes === "function"
570
+ ? options.attributes(request)
571
+ : options.attributes ?? {};
572
+
573
+ return tracer.withSpan(
574
+ spanName,
575
+ async (span: TraceSpan): Promise<Response> => {
576
+ span.setAttribute(
577
+ "http.request.method",
578
+ request.method.toUpperCase()
579
+ );
580
+ span.setAttribute(
581
+ "url.path",
582
+ request.nextUrl.pathname
583
+ );
584
+ let response: Response;
585
+ try {
586
+ response = await next();
587
+ span.setAttribute(
588
+ "http.response.status_code",
589
+ response.status
590
+ );
591
+ if (response.status >= 500) {
592
+ span.setStatus("error", `HTTP ${response.status}`);
593
+ }
594
+ } catch (error) {
595
+ span.setStatus(
596
+ "error",
597
+ error instanceof Error
598
+ ? error.message
599
+ : "HTTP request failed."
600
+ );
601
+ throw error;
602
+ }
603
+ return options.includeResponseHeaders === false
604
+ ? response
605
+ : cloneResponseWithTraceHeaders(
606
+ response,
607
+ span.context
608
+ );
609
+ },
610
+ {
611
+ parent: incoming,
612
+ kind: "server",
613
+ attributes: optionAttributes,
614
+ }
615
+ );
616
+ };
617
+
618
+ return middleware;
619
+ }
620
+
621
+ export function createMemoryTraceSpanExporter(): MemoryTraceSpanExporter {
622
+ const records: TraceSpanRecord[] = [];
623
+ return {
624
+ export(span: TraceSpanRecord): void {
625
+ records.push(cloneTraceSpanRecord(span));
626
+ },
627
+ spans(): TraceSpanRecord[] {
628
+ return records.map(cloneTraceSpanRecord);
629
+ },
630
+ clear(): void {
631
+ records.length = 0;
632
+ },
633
+ };
634
+ }
635
+
636
+ export function createTraceMetricsExporter(
637
+ registry: TraceMetricsRegistryLike,
638
+ options: TraceMetricsOptions = {}
639
+ ): TraceSpanExporter {
640
+ const prefix = normalizeMetricPrefix(
641
+ options.prefix ?? "bcp_trace"
642
+ );
643
+ const labelNames = [
644
+ "kind",
645
+ "status",
646
+ ...(options.includeSpanName ? ["span"] : []),
647
+ ];
648
+ const spans = registry.counter(
649
+ `${prefix}_spans_total`,
650
+ {
651
+ help: "Completed BCP trace spans.",
652
+ labelNames,
653
+ }
654
+ );
655
+ const duration = registry.histogram(
656
+ `${prefix}_span_duration_seconds`,
657
+ {
658
+ help: "BCP trace span duration in seconds.",
659
+ labelNames,
660
+ }
661
+ );
662
+
663
+ return {
664
+ export(span: TraceSpanRecord): void {
665
+ const labels: Record<string, TraceAttributeValue> = {
666
+ kind: span.kind,
667
+ status: span.status,
668
+ };
669
+ if (options.includeSpanName) {
670
+ labels.span = span.name;
671
+ }
672
+ spans.inc(1, labels);
673
+ duration.observe(span.durationMs / 1000, labels);
674
+ },
675
+ };
676
+ }
677
+
678
+ export function createCompositeTraceSpanExporter(
679
+ exporters: readonly TraceSpanExporter[]
680
+ ): TraceSpanExporter {
681
+ const list = [...exporters];
682
+ return {
683
+ async export(span: TraceSpanRecord): Promise<void> {
684
+ for (const exporter of list) {
685
+ await exporter.export(cloneTraceSpanRecord(span));
686
+ }
687
+ },
688
+ async shutdown(): Promise<void> {
689
+ for (const exporter of [...list].reverse()) {
690
+ await exporter.shutdown?.();
691
+ }
692
+ },
693
+ };
694
+ }
695
+
696
+ export function getTraceLogFields(
697
+ context: TraceContext | undefined = currentTraceContext()
698
+ ): Record<string, string> {
699
+ if (!context) {
700
+ return {};
701
+ }
702
+ const normalized = normalizeTraceContext(context);
703
+ return {
704
+ traceId: normalized.traceId,
705
+ spanId: normalized.spanId,
706
+ correlationId: normalized.correlationId,
707
+ };
708
+ }
709
+
710
+ function createDefaultTraceIdFactory(): TraceIdFactory {
711
+ return {
712
+ traceId(): string {
713
+ return randomBytes(16).toString("hex");
714
+ },
715
+ spanId(): string {
716
+ return randomBytes(8).toString("hex");
717
+ },
718
+ correlationId(): string {
719
+ return randomUUID();
720
+ },
721
+ };
722
+ }
723
+
724
+ function cloneResponseWithTraceHeaders(
725
+ response: Response,
726
+ context: TraceContext
727
+ ): Response {
728
+ const headers = new Headers(response.headers);
729
+ injectTraceHeaders(headers, context);
730
+ return new Response(response.body, {
731
+ status: response.status,
732
+ statusText: response.statusText,
733
+ headers,
734
+ });
735
+ }
736
+
737
+ function normalizeTraceContext(context: TraceContext): TraceContext {
738
+ const tracestate = normalizeOptionalText(context.tracestate);
739
+ return {
740
+ traceId: normalizeTraceId(context.traceId),
741
+ spanId: normalizeSpanId(context.spanId),
742
+ traceFlags: normalizeTraceFlags(context.traceFlags),
743
+ correlationId: normalizeCorrelationId(context.correlationId),
744
+ ...(tracestate ? { tracestate } : {}),
745
+ };
746
+ }
747
+
748
+ function cloneTraceContext(context: TraceContext): TraceContext {
749
+ return { ...context };
750
+ }
751
+
752
+ function cloneTraceSpanRecord(span: TraceSpanRecord): TraceSpanRecord {
753
+ return {
754
+ ...span,
755
+ attributes: { ...span.attributes },
756
+ events: span.events.map(cloneTraceEvent),
757
+ };
758
+ }
759
+
760
+ function cloneTraceEvent(event: TraceSpanEvent): TraceSpanEvent {
761
+ return {
762
+ ...event,
763
+ attributes: { ...event.attributes },
764
+ };
765
+ }
766
+
767
+ function normalizeAttributes(
768
+ attributes: TraceAttributes | undefined
769
+ ): Record<string, TraceAttributeValue> {
770
+ const normalized: Record<string, TraceAttributeValue> = {};
771
+ for (const [rawName, value] of Object.entries(attributes ?? {})) {
772
+ normalized[
773
+ normalizeName(rawName, "attribute name")
774
+ ] = normalizeAttributeValue(value);
775
+ }
776
+ return normalized;
777
+ }
778
+
779
+ function normalizeAttributeValue(
780
+ value: TraceAttributeValue
781
+ ): TraceAttributeValue {
782
+ if (
783
+ typeof value !== "string" &&
784
+ typeof value !== "number" &&
785
+ typeof value !== "boolean"
786
+ ) {
787
+ throw new TypeError(
788
+ "BCP Observability: trace attributes must be string, number or boolean values."
789
+ );
790
+ }
791
+ if (typeof value === "number" && !Number.isFinite(value)) {
792
+ throw new TypeError(
793
+ "BCP Observability: numeric trace attributes must be finite."
794
+ );
795
+ }
796
+ return value;
797
+ }
798
+
799
+ function normalizeStatus(status: TraceSpanStatus): TraceSpanStatus {
800
+ if (status !== "unset" && status !== "ok" && status !== "error") {
801
+ throw new TypeError(
802
+ "BCP Observability: invalid span status."
803
+ );
804
+ }
805
+ return status;
806
+ }
807
+
808
+ function normalizeTraceId(value: string): string {
809
+ const normalized = value.trim().toLowerCase();
810
+ if (!/^[0-9a-f]{32}$/.test(normalized) || /^0+$/.test(normalized)) {
811
+ throw new TypeError(
812
+ "BCP Observability: traceId must be 32 non-zero hexadecimal characters."
813
+ );
814
+ }
815
+ return normalized;
816
+ }
817
+
818
+ function normalizeSpanId(value: string): string {
819
+ const normalized = value.trim().toLowerCase();
820
+ if (!/^[0-9a-f]{16}$/.test(normalized) || /^0+$/.test(normalized)) {
821
+ throw new TypeError(
822
+ "BCP Observability: spanId must be 16 non-zero hexadecimal characters."
823
+ );
824
+ }
825
+ return normalized;
826
+ }
827
+
828
+ function normalizeTraceFlags(value: string): string {
829
+ const normalized = value.trim().toLowerCase();
830
+ if (!/^[0-9a-f]{2}$/.test(normalized)) {
831
+ throw new TypeError(
832
+ "BCP Observability: traceFlags must be two hexadecimal characters."
833
+ );
834
+ }
835
+ return normalized;
836
+ }
837
+
838
+ function normalizeCorrelationId(value: string): string {
839
+ return normalizeName(value, "correlationId");
840
+ }
841
+
842
+ function normalizeName(value: string, field: string): string {
843
+ const normalized = String(value ?? "").trim();
844
+ if (!normalized) {
845
+ throw new TypeError(
846
+ `BCP Observability: ${field} must be a non-empty string.`
847
+ );
848
+ }
849
+ return normalized;
850
+ }
851
+
852
+ function normalizeOptionalText(
853
+ value: string | undefined
854
+ ): string | undefined {
855
+ if (value === undefined) {
856
+ return undefined;
857
+ }
858
+ const normalized = value.trim();
859
+ return normalized || undefined;
860
+ }
861
+
862
+ function assertTimestamp(value: number, field: string): void {
863
+ if (!Number.isFinite(value)) {
864
+ throw new TypeError(
865
+ `BCP Observability: ${field} must be a finite number.`
866
+ );
867
+ }
868
+ }
869
+
870
+ function normalizeMetricPrefix(value: string): string {
871
+ const normalized = value.trim();
872
+ if (!/^[a-zA-Z_:][a-zA-Z0-9_:]*$/.test(normalized)) {
873
+ throw new TypeError(
874
+ "BCP Observability: trace metrics prefix contains unsupported characters."
875
+ );
876
+ }
877
+ return normalized;
878
+ }