@velum-labs/routekit-tracing 1.4.0 → 1.5.1

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/dist/carrier.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Context } from "@opentelemetry/api";
2
2
  export type TraceCarrier = {
3
3
  traceparent: string;
4
+ tracestate?: string;
4
5
  baggage?: string;
5
6
  };
6
7
  export declare function newTraceId(): string;
package/dist/carrier.js CHANGED
@@ -32,6 +32,7 @@ export function carrierOf(context) {
32
32
  return newSessionCarrier().carrier;
33
33
  return {
34
34
  traceparent: target.traceparent,
35
+ ...(target.tracestate !== undefined ? { tracestate: target.tracestate } : {}),
35
36
  ...(target.baggage !== undefined ? { baggage: target.baggage } : {})
36
37
  };
37
38
  }
@@ -44,17 +45,24 @@ export function carrierFromHeaders(headers) {
44
45
  if (traceparent === undefined || traceparent.length === 0)
45
46
  return undefined;
46
47
  const baggage = first(headers.baggage);
47
- return { traceparent, ...(baggage !== undefined && baggage.length > 0 ? { baggage } : {}) };
48
+ const tracestate = first(headers.tracestate);
49
+ return {
50
+ traceparent,
51
+ ...(tracestate !== undefined && tracestate.length > 0 ? { tracestate } : {}),
52
+ ...(baggage !== undefined && baggage.length > 0 ? { baggage } : {})
53
+ };
48
54
  }
49
55
  export function headersOf(carrier) {
50
56
  return {
51
57
  traceparent: carrier.traceparent,
58
+ ...(carrier.tracestate !== undefined ? { tracestate: carrier.tracestate } : {}),
52
59
  ...(carrier.baggage !== undefined ? { baggage: carrier.baggage } : {})
53
60
  };
54
61
  }
55
62
  export function envOf(carrier) {
56
63
  return {
57
64
  TRACEPARENT: carrier.traceparent,
65
+ ...(carrier.tracestate !== undefined ? { TRACESTATE: carrier.tracestate } : {}),
58
66
  ...(carrier.baggage !== undefined ? { BAGGAGE: carrier.baggage } : {})
59
67
  };
60
68
  }
@@ -64,6 +72,9 @@ export function carrierFromEnv(env = process.env) {
64
72
  return undefined;
65
73
  return {
66
74
  traceparent,
75
+ ...(env.TRACESTATE !== undefined && env.TRACESTATE.length > 0
76
+ ? { tracestate: env.TRACESTATE }
77
+ : {}),
67
78
  ...(env.BAGGAGE !== undefined && env.BAGGAGE.length > 0 ? { baggage: env.BAGGAGE } : {})
68
79
  };
69
80
  }
@@ -81,7 +92,10 @@ export function withBaggage(carrier, entries) {
81
92
  }
82
93
  }
83
94
  const baggage = [...members.values()].join(",");
84
- return { traceparent: carrier.traceparent, ...(baggage.length > 0 ? { baggage } : {}) };
95
+ return {
96
+ ...carrier,
97
+ ...(baggage.length > 0 ? { baggage } : {})
98
+ };
85
99
  }
86
100
  export function baggageOf(carrier, keys) {
87
101
  if (carrier?.baggage === undefined)
@@ -0,0 +1,8 @@
1
+ import { type Attributes } from "@opentelemetry/api";
2
+ /** Structured OTel events use the existing provider and its export attribute policy. */
3
+ export declare function emitTraceEvent(input: Readonly<{
4
+ scope: string;
5
+ name: string;
6
+ attributes: Attributes;
7
+ error?: boolean;
8
+ }>): void;
package/dist/event.js ADDED
@@ -0,0 +1,17 @@
1
+ import { context } from "@opentelemetry/api";
2
+ import { logs, SeverityNumber } from "@opentelemetry/api-logs";
3
+ /** Structured OTel events use the existing provider and its export attribute policy. */
4
+ export function emitTraceEvent(input) {
5
+ try {
6
+ logs.getLogger(input.scope).emit({
7
+ eventName: input.name,
8
+ context: context.active(),
9
+ severityNumber: input.error ? SeverityNumber.ERROR : SeverityNumber.INFO,
10
+ severityText: input.error ? "ERROR" : "INFO",
11
+ attributes: input.attributes
12
+ });
13
+ }
14
+ catch {
15
+ // An unavailable exporter must not affect the operation being observed.
16
+ }
17
+ }
@@ -1,8 +1,13 @@
1
+ import type { AttributeValue } from "@opentelemetry/api";
1
2
  import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
2
3
  import type { LogRecordExporter, ReadableLogRecord } from "@opentelemetry/sdk-logs";
3
4
  export type AttributePolicy = {
4
5
  allowed: ReadonlySet<string>;
5
6
  redactedAttribute: string;
7
+ normalizeAttribute?: (key: string, value: AttributeValue) => AttributeValue | undefined;
8
+ normalizeSpanName?: (span: ReadableSpan) => string;
9
+ normalizeEventName?: (name: string) => string;
10
+ normalizeSeverityText?: (value: string) => string | undefined;
6
11
  };
7
12
  export declare function toExportableSpan(span: ReadableSpan, policy: AttributePolicy): ReadableSpan;
8
13
  export declare function toExportableEvent(record: ReadableLogRecord, policy: AttributePolicy): ReadableLogRecord;
@@ -1,41 +1,73 @@
1
+ const URL_IN_SPAN_NAME = /\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi;
2
+ const RELATIVE_URL_QUERY_IN_SPAN_NAME = /(^|\s)(\/[^\s?#]*)[?#][^\s]*/g;
3
+ const UUID_IN_SPAN_NAME = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi;
4
+ function exportableSpanName(name) {
5
+ return name
6
+ .replace(URL_IN_SPAN_NAME, "[url]")
7
+ .replace(RELATIVE_URL_QUERY_IN_SPAN_NAME, "$1$2")
8
+ .replace(UUID_IN_SPAN_NAME, "[id]")
9
+ .slice(0, 256);
10
+ }
1
11
  function filterAttributes(attributes, policy) {
2
12
  const kept = {};
3
13
  let dropped = 0;
14
+ let changed = false;
4
15
  for (const [key, value] of Object.entries(attributes)) {
5
- if (policy.allowed.has(key))
6
- kept[key] = value;
7
- else
16
+ if (!policy.allowed.has(key) || value === undefined) {
17
+ dropped += 1;
18
+ continue;
19
+ }
20
+ const normalized = policy.normalizeAttribute === undefined
21
+ ? value
22
+ : policy.normalizeAttribute(key, value);
23
+ if (normalized === undefined)
8
24
  dropped += 1;
25
+ else {
26
+ kept[key] = normalized;
27
+ if (normalized !== value)
28
+ changed = true;
29
+ }
9
30
  }
10
- return { kept, dropped };
31
+ return { kept, dropped, changed };
11
32
  }
12
33
  export function toExportableSpan(span, policy) {
13
34
  const own = filterAttributes(span.attributes, policy);
14
35
  let childDrops = 0;
36
+ let childChanged = false;
15
37
  const events = span.events.map((event) => {
38
+ const name = policy.normalizeEventName?.(event.name) ?? event.name;
39
+ if (name !== event.name)
40
+ childChanged = true;
16
41
  if (event.attributes === undefined)
17
- return event;
42
+ return name === event.name ? event : { ...event, name };
18
43
  const filtered = filterAttributes(event.attributes, policy);
19
44
  childDrops += filtered.dropped;
20
- return filtered.dropped === 0 ? event : { ...event, attributes: filtered.kept };
45
+ childChanged ||= filtered.changed;
46
+ return filtered.dropped === 0 && !filtered.changed && name === event.name
47
+ ? event
48
+ : { ...event, name, attributes: filtered.kept };
21
49
  });
22
50
  const links = span.links.map((link) => {
23
51
  if (link.attributes === undefined)
24
52
  return link;
25
53
  const filtered = filterAttributes(link.attributes, policy);
26
54
  childDrops += filtered.dropped;
27
- return filtered.dropped === 0 ? link : { ...link, attributes: filtered.kept };
55
+ childChanged ||= filtered.changed;
56
+ return filtered.dropped === 0 && !filtered.changed ? link : { ...link, attributes: filtered.kept };
28
57
  });
29
- if (own.dropped + childDrops === 0)
58
+ const name = exportableSpanName(policy.normalizeSpanName?.(span) ?? span.name);
59
+ const status = span.status.message === undefined ? span.status : { code: span.status.code };
60
+ if (own.dropped + childDrops === 0 && !own.changed && !childChanged && name === span.name && status === span.status) {
30
61
  return span;
62
+ }
31
63
  return {
32
- name: span.name,
64
+ name,
33
65
  kind: span.kind,
34
66
  spanContext: () => span.spanContext(),
35
67
  ...(span.parentSpanContext !== undefined ? { parentSpanContext: span.parentSpanContext } : {}),
36
68
  startTime: span.startTime,
37
69
  endTime: span.endTime,
38
- status: span.status,
70
+ status,
39
71
  attributes: { ...own.kept, [policy.redactedAttribute]: true },
40
72
  links,
41
73
  events,
@@ -50,16 +82,25 @@ export function toExportableSpan(span, policy) {
50
82
  }
51
83
  export function toExportableEvent(record, policy) {
52
84
  const filtered = filterAttributes(record.attributes, policy);
53
- if (filtered.dropped === 0)
85
+ const bodyDropped = record.body !== undefined;
86
+ const eventName = record.eventName === undefined
87
+ ? undefined
88
+ : policy.normalizeEventName?.(record.eventName) ?? record.eventName;
89
+ const severityText = record.severityText === undefined
90
+ ? undefined
91
+ : policy.normalizeSeverityText === undefined
92
+ ? record.severityText
93
+ : policy.normalizeSeverityText(record.severityText);
94
+ if (filtered.dropped === 0 && !filtered.changed && !bodyDropped &&
95
+ eventName === record.eventName && severityText === record.severityText)
54
96
  return record;
55
97
  return {
56
98
  hrTime: record.hrTime,
57
99
  hrTimeObserved: record.hrTimeObserved,
58
100
  ...(record.spanContext !== undefined ? { spanContext: record.spanContext } : {}),
59
- ...(record.severityText !== undefined ? { severityText: record.severityText } : {}),
101
+ ...(severityText !== undefined ? { severityText } : {}),
60
102
  ...(record.severityNumber !== undefined ? { severityNumber: record.severityNumber } : {}),
61
- ...(record.body !== undefined ? { body: record.body } : {}),
62
- ...(record.eventName !== undefined ? { eventName: record.eventName } : {}),
103
+ ...(eventName !== undefined ? { eventName } : {}),
63
104
  resource: record.resource,
64
105
  instrumentationScope: record.instrumentationScope,
65
106
  attributes: { ...filtered.kept, [policy.redactedAttribute]: true },
package/dist/index.d.ts CHANGED
@@ -12,3 +12,4 @@ export { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-tr
12
12
  export type { SpanProcessor } from "@opentelemetry/sdk-trace-base";
13
13
  export { InMemoryLogRecordExporter, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";
14
14
  export type { LogRecordProcessor } from "@opentelemetry/sdk-logs";
15
+ export { emitTraceEvent } from "./event.js";
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@ export { isLoopbackOtlpEndpoint, PolicyLogExporter, PolicySpanExporter, toExport
5
5
  export { flushTracing, initTracing, isEventExportConfigured, isTraceExportConfigured, isTracingActive, resetTracingForTest, shutdownTracing, tracingServiceName } from "./provider.js";
6
6
  export { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
7
7
  export { InMemoryLogRecordExporter, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";
8
+ export { emitTraceEvent } from "./event.js";
@@ -4,6 +4,7 @@ import type { AttributePolicy } from "./exportable.js";
4
4
  export type InitTracingOptions = {
5
5
  serviceName: string;
6
6
  attributePolicy: AttributePolicy;
7
+ allowLoopbackFullFidelity?: boolean;
7
8
  fullFidelityEnvironmentVariable?: string;
8
9
  logPrefix?: string;
9
10
  spanProcessors?: SpanProcessor[];
package/dist/provider.js CHANGED
@@ -36,7 +36,7 @@ export function initTracing(options) {
36
36
  if (traceEndpoint !== undefined) {
37
37
  const fullFidelity = (options.fullFidelityEnvironmentVariable !== undefined &&
38
38
  process.env[options.fullFidelityEnvironmentVariable] === "1") ||
39
- isLoopbackOtlpEndpoint(traceEndpoint);
39
+ (options.allowLoopbackFullFidelity !== false && isLoopbackOtlpEndpoint(traceEndpoint));
40
40
  if (options.logPrefix !== undefined) {
41
41
  process.stderr.write(fullFidelity
42
42
  ? `${options.logPrefix}: exporting full traces to ${traceEndpoint}\n`
@@ -48,7 +48,7 @@ export function initTracing(options) {
48
48
  if (eventEndpoint !== undefined) {
49
49
  const fullFidelity = (options.fullFidelityEnvironmentVariable !== undefined &&
50
50
  process.env[options.fullFidelityEnvironmentVariable] === "1") ||
51
- isLoopbackOtlpEndpoint(eventEndpoint);
51
+ (options.allowLoopbackFullFidelity !== false && isLoopbackOtlpEndpoint(eventEndpoint));
52
52
  logProcessors.push(new BatchLogRecordProcessor({
53
53
  exporter: new PolicyLogExporter(new OTLPLogExporter(), options.attributePolicy, fullFidelity),
54
54
  scheduledDelayMillis: 500
@@ -71,8 +71,10 @@ export const isTracingActive = () => provider !== undefined &&
71
71
  hasEventListeners() ||
72
72
  extraProcessorsInstalled);
73
73
  export async function flushTracing() {
74
- await provider?.forceFlush().catch(() => undefined);
75
- await loggerProvider?.forceFlush().catch(() => undefined);
74
+ await Promise.allSettled([
75
+ provider?.forceFlush(),
76
+ loggerProvider?.forceFlush()
77
+ ]);
76
78
  }
77
79
  export async function shutdownTracing() {
78
80
  const activeProvider = provider;
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { baggageOf, carrierFromEnv, envOf, newSessionCarrier, traceIdOf, withBaggage } from "../index.js";
3
+ import { baggageOf, carrierFromEnv, envOf, newSessionCarrier, toExportableEvent, toExportableSpan, traceIdOf, withBaggage } from "../index.js";
4
4
  test("generic trace carriers cross environment and baggage boundaries", () => {
5
5
  const created = newSessionCarrier();
6
6
  const enriched = withBaggage(created.carrier, { "route.owner": "example", attempt: 2 });
@@ -12,3 +12,98 @@ test("generic trace carriers cross environment and baggage boundaries", () => {
12
12
  attempt: "2"
13
13
  });
14
14
  });
15
+ test("export policy removes URL-bearing names, status messages, and denied exception fields", () => {
16
+ const span = {
17
+ name: "fetch GET https://provider.test/private/path?token=secret",
18
+ status: { code: 2, message: "postgres://user:password@database/private" },
19
+ attributes: { "http.method": "GET", "http.url": "https://provider.test/private" },
20
+ events: [
21
+ {
22
+ name: "exception",
23
+ attributes: {
24
+ "exception.type": "TypeError",
25
+ "exception.message": "secret",
26
+ "exception.stacktrace": "secret stack"
27
+ }
28
+ }
29
+ ],
30
+ links: [],
31
+ droppedAttributesCount: 0
32
+ };
33
+ const exported = toExportableSpan(span, {
34
+ allowed: new Set(["http.method", "exception.type"]),
35
+ redactedAttribute: "test.redacted"
36
+ });
37
+ assert.equal(exported.name, "fetch GET [url]");
38
+ assert.deepEqual(exported.status, { code: 2 });
39
+ assert.deepEqual(exported.attributes, { "http.method": "GET", "test.redacted": true });
40
+ assert.deepEqual(exported.events[0]?.attributes, {
41
+ "exception.type": "TypeError"
42
+ });
43
+ });
44
+ test("export policy removes log bodies even when every attribute is allowed", () => {
45
+ const record = {
46
+ body: "credential=secret",
47
+ attributes: { "http.method": "POST" },
48
+ droppedAttributesCount: 0
49
+ };
50
+ const exported = toExportableEvent(record, {
51
+ allowed: new Set(["http.method"]),
52
+ redactedAttribute: "test.redacted"
53
+ });
54
+ assert.equal(exported.body, undefined);
55
+ assert.deepEqual(exported.attributes, {
56
+ "http.method": "POST",
57
+ "test.redacted": true
58
+ });
59
+ });
60
+ test("W3C tracestate survives headers, environment, context and baggage enrichment", async () => {
61
+ const { carrierFromHeaders, carrierOf, contextOf, headersOf, initTracing, resetTracingForTest } = await import("../index.js");
62
+ await resetTracingForTest();
63
+ initTracing({ serviceName: "carrier-test", attributePolicy: { allowed: new Set(), redactedAttribute: "redacted" } });
64
+ try {
65
+ const carrier = { ...newSessionCarrier().carrier, tracestate: "vendor=opaque" };
66
+ const enriched = withBaggage(carrier, { stage: "checkout" });
67
+ assert.equal(enriched.tracestate, "vendor=opaque");
68
+ assert.deepEqual(carrierFromHeaders(headersOf(enriched)), enriched);
69
+ assert.deepEqual(carrierFromEnv(envOf(enriched)), enriched);
70
+ assert.equal(carrierOf(contextOf(enriched)).tracestate, "vendor=opaque");
71
+ }
72
+ finally {
73
+ await resetTracingForTest();
74
+ }
75
+ });
76
+ test("trace and log flushes start together even when the trace flush is pending", async () => {
77
+ const { initTracing, flushTracing, resetTracingForTest } = await import("../index.js");
78
+ await resetTracingForTest();
79
+ let finishTrace;
80
+ const pendingTrace = new Promise((resolve) => { finishTrace = resolve; });
81
+ let logFlushStarted = false;
82
+ initTracing({
83
+ serviceName: "parallel-flush-test",
84
+ attributePolicy: { allowed: new Set(), redactedAttribute: "redacted" },
85
+ spanProcessors: [{
86
+ onStart() { },
87
+ onEnd() { },
88
+ forceFlush: () => pendingTrace,
89
+ shutdown: async () => { }
90
+ }],
91
+ logRecordProcessors: [{
92
+ onEmit() { },
93
+ forceFlush: async () => { logFlushStarted = true; },
94
+ shutdown: async () => { }
95
+ }]
96
+ });
97
+ try {
98
+ const flushing = flushTracing();
99
+ await new Promise((resolve) => setImmediate(resolve));
100
+ assert.equal(logFlushStarted, true);
101
+ assert.ok(finishTrace);
102
+ finishTrace();
103
+ await flushing;
104
+ }
105
+ finally {
106
+ finishTrace?.();
107
+ await resetTracingForTest();
108
+ }
109
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-tracing",
3
3
  "private": false,
4
- "version": "1.4.0",
4
+ "version": "1.5.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",