@zudojs/observability 1.0.1 → 1.1.0

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 (34) hide show
  1. package/README.md +48 -0
  2. package/dist/errors/observabilityError.core.d.ts +8 -9
  3. package/dist/errors/observabilityError.core.js +7 -16
  4. package/dist/index.d.ts +3 -3
  5. package/dist/index.js +3 -3
  6. package/dist/logLevel/index.d.ts +1 -0
  7. package/dist/logLevel/index.js +1 -0
  8. package/dist/logLevel/logLevel.bridge.d.ts +25 -0
  9. package/dist/logLevel/logLevel.bridge.js +38 -0
  10. package/dist/metrics/metrics.registry.d.ts +6 -0
  11. package/dist/metrics/metrics.registry.js +14 -1
  12. package/dist/observability/observability.core.js +6 -0
  13. package/dist/propagation/index.d.ts +1 -0
  14. package/dist/propagation/index.js +1 -0
  15. package/dist/propagation/propagation.core.d.ts +7 -1
  16. package/dist/propagation/propagation.core.js +16 -6
  17. package/dist/propagation/propagation.traceparent.d.ts +28 -0
  18. package/dist/propagation/propagation.traceparent.js +58 -0
  19. package/dist/redaction/redaction.core.js +8 -2
  20. package/dist/tracing/index.d.ts +2 -2
  21. package/dist/tracing/index.js +2 -2
  22. package/dist/tracing/span/index.d.ts +1 -1
  23. package/dist/tracing/span/index.js +1 -1
  24. package/dist/tracing/span/spanContext.type.d.ts +17 -1
  25. package/dist/tracing/span/spanContext.type.js +36 -6
  26. package/dist/tracing/tracer/index.d.ts +1 -0
  27. package/dist/tracing/tracer/index.js +1 -0
  28. package/dist/tracing/tracer/tracer.active.d.ts +19 -0
  29. package/dist/tracing/tracer/tracer.active.js +67 -0
  30. package/dist/tracing/tracer/tracer.core.d.ts +5 -0
  31. package/dist/tracing/tracer/tracer.core.js +15 -6
  32. package/dist/tracing/tracer/tracer.parent.d.ts +28 -0
  33. package/dist/tracing/tracer/tracer.parent.js +36 -0
  34. package/package.json +2 -2
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Structured logging, metrics, tracing, context propagation, and telemetry exporters for Zudojs applications.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-observability](https://zudojs.oyinlola.site/docs/packages-observability) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-observability.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -68,6 +74,12 @@ await obs.logger.flush();
68
74
  Every record carries `traceId` and `spanId` when a propagation context is
69
75
  active, so logs and traces line up without threading IDs by hand.
70
76
 
77
+ `LogLevel` here counts upward in severity (`TRACE = 0 … FATAL = 5`), the
78
+ opposite of `@zudojs/logger`'s `LoggerLevel` (`FATAL = 0 … TRACE = 5`).
79
+ Never pass a raw level number between the two; convert with
80
+ `toLoggerLevel(LogLevel.ERROR)` (→ `1`) and `fromLoggerLevel(1)`
81
+ (→ `LogLevel.ERROR`).
82
+
71
83
  ## Redaction
72
84
 
73
85
  Redaction is off unless you configure it, and on once you do. The logger
@@ -143,6 +155,10 @@ const obs = createObservability({
143
155
  });
144
156
  ```
145
157
 
158
+ `metrics.onCardinalityLimit` fires once per rejected series, and the facade
159
+ raises `onError` once per metric name, so a label explosion is one report
160
+ rather than one per metric call.
161
+
146
162
  One metric name may only ever be one type: registering `counter("latency")`
147
163
  and then `histogram("latency")` throws, because a document carrying the same
148
164
  name as two types is rejected wholesale by OTLP and Prometheus.
@@ -218,6 +234,38 @@ obs.propagation.current(); // undefined outside a run() scope
218
234
  `current()` returns `undefined` when there is no active context, so "no trace"
219
235
  stays distinguishable from a real one.
220
236
 
237
+ A span started without an explicit `parent` joins the active context, so the
238
+ span and the log records written in the same scope share one `traceId`. To
239
+ make a span itself the active context — so logs inside it carry its `spanId`
240
+ and nested spans become its children — use `withSpan` (or
241
+ `tracer.startActiveSpan` on a `DefaultTracer`). It ends the span when the
242
+ callback returns or its promise settles, and records a throw or rejection:
243
+
244
+ ```typescript
245
+ import { withSpan } from "@zudojs/observability";
246
+
247
+ const rows = await withSpan(obs.tracer, "db.query", async (span) => {
248
+ span.setAttribute("db.system", "postgresql");
249
+ obs.logger.info("querying"); // carries this span's traceId and spanId
250
+ return [1, 2, 3];
251
+ });
252
+ ```
253
+
254
+ Inbound trace IDs are validated. `parseTraceparent` reads a W3C
255
+ `traceparent` header and returns `undefined` for anything malformed;
256
+ `formatTraceparent` writes one for an outgoing request. A parent or
257
+ propagation context whose trace or span ID is not valid W3C hex is never
258
+ joined — the span starts a fresh trace instead:
259
+
260
+ ```typescript
261
+ import { formatTraceparent, parseTraceparent } from "@zudojs/observability";
262
+
263
+ const parent = parseTraceparent(request.headers["traceparent"]);
264
+ const span = obs.tracer.startSpan("handle", { parent }); // fresh trace if absent
265
+ const outgoing = formatTraceparent(span.context); // "00-<trace>-<span>-01"
266
+ span.end();
267
+ ```
268
+
221
269
  ## Exporters
222
270
 
223
271
  Console exporters ship for development. They serialize defensively — a
@@ -6,15 +6,14 @@
6
6
  * recorded with a value that cannot be aggregated. Failures in the transport
7
7
  * layer are reported through `ObservabilityConfig.onError` instead.
8
8
  */
9
- import { BaseError, ErrorCode } from "@zudojs/errors";
10
- /** Base error for all observability failures. */
11
- export declare class ObservabilityError extends BaseError {
12
- constructor(message: string, options?: {
13
- readonly code?: ErrorCode;
14
- readonly metadata?: Readonly<Record<string, unknown>>;
15
- readonly cause?: unknown;
16
- });
17
- }
9
+ import { ObservabilityError } from "@zudojs/errors";
10
+ /**
11
+ * Base error for all observability failures (500, not exposed). Owned by
12
+ * `@zudojs/errors` and re-exported here, so `instanceof` matches across
13
+ * both import paths.
14
+ */
15
+ export { ObservabilityError };
16
+ export type { ObservabilityErrorOptions } from "@zudojs/errors";
18
17
  /** An exporter failed to deliver telemetry. */
19
18
  export declare class ExporterError extends ObservabilityError {
20
19
  constructor(exporterName: string, cause?: unknown);
@@ -6,22 +6,13 @@
6
6
  * recorded with a value that cannot be aggregated. Failures in the transport
7
7
  * layer are reported through `ObservabilityConfig.onError` instead.
8
8
  */
9
- import { BaseError, ErrorCode, ErrorCategory, ErrorSeverity, } from "@zudojs/errors";
10
- /** Base error for all observability failures. */
11
- export class ObservabilityError extends BaseError {
12
- constructor(message, options) {
13
- super(message, {
14
- code: options?.code ?? ErrorCode.OPERATION_FAILED,
15
- category: ErrorCategory.INTERNAL,
16
- severity: ErrorSeverity.ERROR,
17
- statusCode: 500,
18
- expose: false,
19
- metadata: options?.metadata,
20
- cause: options?.cause,
21
- });
22
- this.name = "ObservabilityError";
23
- }
24
- }
9
+ import { ErrorCode, ObservabilityError } from "@zudojs/errors";
10
+ /**
11
+ * Base error for all observability failures (500, not exposed). Owned by
12
+ * `@zudojs/errors` and re-exported here, so `instanceof` matches across
13
+ * both import paths.
14
+ */
15
+ export { ObservabilityError };
25
16
  /** An exporter failed to deliver telemetry. */
26
17
  export class ExporterError extends ObservabilityError {
27
18
  constructor(exporterName, cause) {
package/dist/index.d.ts CHANGED
@@ -33,12 +33,12 @@
33
33
  export { LogLevel, SpanStatus, SpanKind, TraceFlags, type LogLevelName, type LogRecord, type LogRecordError, type Logger, type LoggerOptions, type LogTransport, type PropagationContext, type PropagationContextOptions, type PropagationManager, type Counter, type Gauge, type Histogram, type HistogramValue, type MetricsRegistry, type MetricSnapshot, type Span, type SpanContext, type SpanEvent, type SpanOptions, type SpanLimits, type Tracer, type ReadableSpan, type SpanExporter, type LogExporter, type MetricExporter, type SpanProcessor, type SamplingResult, type Sampler, type RedactionConfig, type RedactionMatchMode, type Observability, type ObservabilityConfig, } from "./types.js";
34
34
  export { ObservabilityError, ExporterError, ObservabilityConfigError, MetricValueError, isObservabilityError, } from "./errors/index.js";
35
35
  export { generateTraceId, generateSpanId, isValidTraceId, isValidSpanId, } from "./internal/index.js";
36
- export { logLevelToName, logLevelFromName, parseLogLevel, shouldLog, getLogLevelNames, } from "./logLevel/index.js";
36
+ export { logLevelToName, logLevelFromName, parseLogLevel, shouldLog, getLogLevelNames, toLoggerLevel, fromLoggerLevel, } from "./logLevel/index.js";
37
37
  export { createLogRecord, createErrorLogRecord, serializeError, } from "./logRecord/index.js";
38
38
  export { StructuredLogger, createStructuredLogger } from "./logger/index.js";
39
- export { createPropagationContext, derivePropagationContext, getCurrentContext, requireCurrentContext, AsyncPropagationManager, createPropagationManager, } from "./propagation/index.js";
39
+ export { createPropagationContext, derivePropagationContext, getCurrentContext, requireCurrentContext, AsyncPropagationManager, createPropagationManager, parseTraceparent, formatTraceparent, TRACEPARENT_HEADER, } from "./propagation/index.js";
40
40
  export { DefaultCounter, createCounter, DefaultGauge, createGauge, DefaultHistogram, createHistogram, DEFAULT_BUCKET_BOUNDARIES, DefaultMetricsRegistry, createMetricsRegistry, metricKey, PeriodicMetricReader, createPeriodicMetricReader, type MetricsRegistryOptions, type PeriodicMetricReaderOptions, } from "./metrics/index.js";
41
- export { DefaultSpan, createSpan, createSpanContext, createChildSpanContext, isSampledContext, DefaultTracer, createTracer, type TracerOptions, } from "./tracing/index.js";
41
+ export { DefaultSpan, createSpan, createSpanContext, createChildSpanContext, isSampledContext, isValidSpanContext, DefaultTracer, createTracer, withSpan, type TracerOptions, } from "./tracing/index.js";
42
42
  export { AlwaysOnSampler, AlwaysOffSampler, ProbabilitySampler, ParentBasedSampler, createAlwaysOnSampler, createAlwaysOffSampler, createProbabilitySampler, createParentBasedSampler, isSampled, isRecording, type ParentBasedSamplerOptions, } from "./sampling/index.js";
43
43
  export { ConsoleSpanExporter, ConsoleLogExporter, ConsoleMetricExporter, createConsoleSpanExporter, createConsoleLogExporter, createConsoleMetricExporter, noopLogExporter, noopMetricExporter, safeStringify, type ConsoleExporterOptions, type ConsoleLike, } from "./exporter/index.js";
44
44
  export { BatchSpanProcessor, createBatchSpanProcessor, SimpleSpanProcessor, createSimpleSpanProcessor, BatchLogProcessor, createBatchLogProcessor, noopSpanExporter, type BatchSpanProcessorOptions, type BatchLogProcessorOptions, } from "./processor/index.js";
package/dist/index.js CHANGED
@@ -37,17 +37,17 @@ export { ObservabilityError, ExporterError, ObservabilityConfigError, MetricValu
37
37
  /* ─── Identifiers ───────────────────────────────────────────────────────── */
38
38
  export { generateTraceId, generateSpanId, isValidTraceId, isValidSpanId, } from "./internal/index.js";
39
39
  /* ─── Log Level ─────────────────────────────────────────────────────────── */
40
- export { logLevelToName, logLevelFromName, parseLogLevel, shouldLog, getLogLevelNames, } from "./logLevel/index.js";
40
+ export { logLevelToName, logLevelFromName, parseLogLevel, shouldLog, getLogLevelNames, toLoggerLevel, fromLoggerLevel, } from "./logLevel/index.js";
41
41
  /* ─── Log Record ────────────────────────────────────────────────────────── */
42
42
  export { createLogRecord, createErrorLogRecord, serializeError, } from "./logRecord/index.js";
43
43
  /* ─── Logger ────────────────────────────────────────────────────────────── */
44
44
  export { StructuredLogger, createStructuredLogger } from "./logger/index.js";
45
45
  /* ─── Propagation ───────────────────────────────────────────────────────── */
46
- export { createPropagationContext, derivePropagationContext, getCurrentContext, requireCurrentContext, AsyncPropagationManager, createPropagationManager, } from "./propagation/index.js";
46
+ export { createPropagationContext, derivePropagationContext, getCurrentContext, requireCurrentContext, AsyncPropagationManager, createPropagationManager, parseTraceparent, formatTraceparent, TRACEPARENT_HEADER, } from "./propagation/index.js";
47
47
  /* ─── Metrics ───────────────────────────────────────────────────────────── */
48
48
  export { DefaultCounter, createCounter, DefaultGauge, createGauge, DefaultHistogram, createHistogram, DEFAULT_BUCKET_BOUNDARIES, DefaultMetricsRegistry, createMetricsRegistry, metricKey, PeriodicMetricReader, createPeriodicMetricReader, } from "./metrics/index.js";
49
49
  /* ─── Tracing ───────────────────────────────────────────────────────────── */
50
- export { DefaultSpan, createSpan, createSpanContext, createChildSpanContext, isSampledContext, DefaultTracer, createTracer, } from "./tracing/index.js";
50
+ export { DefaultSpan, createSpan, createSpanContext, createChildSpanContext, isSampledContext, isValidSpanContext, DefaultTracer, createTracer, withSpan, } from "./tracing/index.js";
51
51
  /* ─── Sampling ──────────────────────────────────────────────────────────── */
52
52
  export { AlwaysOnSampler, AlwaysOffSampler, ProbabilitySampler, ParentBasedSampler, createAlwaysOnSampler, createAlwaysOffSampler, createProbabilitySampler, createParentBasedSampler, isSampled, isRecording, } from "./sampling/index.js";
53
53
  /* ─── Exporters ─────────────────────────────────────────────────────────── */
@@ -4,4 +4,5 @@
4
4
  * Level names, conversion, and filtering utilities.
5
5
  */
6
6
  export { logLevelToName, logLevelFromName, parseLogLevel, shouldLog, getLogLevelNames, } from "./logLevel.type.js";
7
+ export { toLoggerLevel, fromLoggerLevel } from "./logLevel.bridge.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,5 @@
4
4
  * Level names, conversion, and filtering utilities.
5
5
  */
6
6
  export { logLevelToName, logLevelFromName, parseLogLevel, shouldLog, getLogLevelNames, } from "./logLevel.type.js";
7
+ export { toLoggerLevel, fromLoggerLevel } from "./logLevel.bridge.js";
7
8
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @zudojs/observability — Level bridge to @zudojs/logger
3
+ *
4
+ * The two packages number their levels in opposite directions: here a higher
5
+ * value is more severe (`TRACE = 0 … FATAL = 5`), in `@zudojs/logger` a lower
6
+ * value is more severe (`FATAL = 0 … TRACE = 5`). Passing a raw number from
7
+ * one to the other inverts filtering, so convert explicitly with these.
8
+ */
9
+ import { LogLevel } from "../types.js";
10
+ /**
11
+ * Converts an observability {@link LogLevel} to the numeric value of the
12
+ * same-named `@zudojs/logger` `LoggerLevel`.
13
+ *
14
+ * Returns `undefined` for {@link LogLevel.OFF}, which has no logger
15
+ * counterpart, and for any value outside the enum.
16
+ */
17
+ export declare function toLoggerLevel(level: LogLevel): number | undefined;
18
+ /**
19
+ * Converts a numeric `@zudojs/logger` `LoggerLevel` to the same-named
20
+ * observability {@link LogLevel}.
21
+ *
22
+ * Returns `undefined` for a value outside `LoggerLevel` (`0`–`5`).
23
+ */
24
+ export declare function fromLoggerLevel(level: number): LogLevel | undefined;
25
+ //# sourceMappingURL=logLevel.bridge.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @zudojs/observability — Level bridge to @zudojs/logger
3
+ *
4
+ * The two packages number their levels in opposite directions: here a higher
5
+ * value is more severe (`TRACE = 0 … FATAL = 5`), in `@zudojs/logger` a lower
6
+ * value is more severe (`FATAL = 0 … TRACE = 5`). Passing a raw number from
7
+ * one to the other inverts filtering, so convert explicitly with these.
8
+ */
9
+ import { LogLevel } from "../types.js";
10
+ /** The most verbose `@zudojs/logger` `LoggerLevel` value (`TRACE`). */
11
+ const LOGGER_TRACE = 5;
12
+ /**
13
+ * Converts an observability {@link LogLevel} to the numeric value of the
14
+ * same-named `@zudojs/logger` `LoggerLevel`.
15
+ *
16
+ * Returns `undefined` for {@link LogLevel.OFF}, which has no logger
17
+ * counterpart, and for any value outside the enum.
18
+ */
19
+ export function toLoggerLevel(level) {
20
+ if (!Number.isInteger(level) || level < LogLevel.TRACE)
21
+ return undefined;
22
+ if (level > LogLevel.FATAL)
23
+ return undefined;
24
+ return LOGGER_TRACE - level;
25
+ }
26
+ /**
27
+ * Converts a numeric `@zudojs/logger` `LoggerLevel` to the same-named
28
+ * observability {@link LogLevel}.
29
+ *
30
+ * Returns `undefined` for a value outside `LoggerLevel` (`0`–`5`).
31
+ */
32
+ export function fromLoggerLevel(level) {
33
+ if (!Number.isInteger(level) || level < 0 || level > LOGGER_TRACE) {
34
+ return undefined;
35
+ }
36
+ return (LOGGER_TRACE - level);
37
+ }
38
+ //# sourceMappingURL=logLevel.bridge.js.map
@@ -52,6 +52,12 @@ export declare class DefaultMetricsRegistry implements MetricsRegistry {
52
52
  * documented ceiling is not quietly doubled by the overflow path.
53
53
  */
54
54
  private readonly maxOverflow;
55
+ /**
56
+ * Series already reported to `onCardinalityLimit`. Kept apart from the
57
+ * overflow cache, whose small bound made the callback re-fire for every
58
+ * repeat of an already-rejected series once it was full.
59
+ */
60
+ private readonly rejected;
55
61
  constructor(options?: MetricsRegistryOptions);
56
62
  private create;
57
63
  private obtain;
@@ -25,6 +25,8 @@ export function metricKey(type, name, labels) {
25
25
  const DEFAULT_MAX_SERIES = 10_000;
26
26
  /** Upper bound on the detached series kept for callers past the cap. */
27
27
  const MAX_OVERFLOW_SERIES = 1_024;
28
+ /** Upper bound on the rejected series remembered for de-duplicated reporting. */
29
+ const MAX_REJECTED_KEYS = 10_000;
28
30
  /**
29
31
  * In-memory metrics registry. Creates, caches, and manages metrics.
30
32
  */
@@ -50,6 +52,12 @@ export class DefaultMetricsRegistry {
50
52
  * documented ceiling is not quietly doubled by the overflow path.
51
53
  */
52
54
  maxOverflow;
55
+ /**
56
+ * Series already reported to `onCardinalityLimit`. Kept apart from the
57
+ * overflow cache, whose small bound made the callback re-fire for every
58
+ * repeat of an already-rejected series once it was full.
59
+ */
60
+ rejected = new Set();
53
61
  constructor(options) {
54
62
  this.maxSeries = options?.maxSeries ?? DEFAULT_MAX_SERIES;
55
63
  this.maxOverflow = Math.max(1, Math.min(this.maxSeries, MAX_OVERFLOW_SERIES));
@@ -83,7 +91,11 @@ export class DefaultMetricsRegistry {
83
91
  const cached = this.overflow.get(key);
84
92
  if (cached)
85
93
  return cached;
86
- this.onCardinalityLimit?.(name, this.metrics.size);
94
+ if (!this.rejected.has(key)) {
95
+ if (this.rejected.size < MAX_REJECTED_KEYS)
96
+ this.rejected.add(key);
97
+ this.onCardinalityLimit?.(name, this.metrics.size);
98
+ }
87
99
  const detached = this.create(type, name, labels);
88
100
  if (this.overflow.size < this.maxOverflow)
89
101
  this.overflow.set(key, detached);
@@ -170,6 +182,7 @@ export class DefaultMetricsRegistry {
170
182
  clear() {
171
183
  this.metrics.clear();
172
184
  this.overflow.clear();
185
+ this.rejected.clear();
173
186
  this.typeByName.clear();
174
187
  }
175
188
  }
@@ -185,10 +185,16 @@ function buildPipeline(config) {
185
185
  // When the caller supplied their own processors, they own the exporter's
186
186
  // lifecycle too; otherwise our processor closes it on shutdown.
187
187
  /* ── Metrics ─────────────────────────────────────────────────────────── */
188
+ // onError hears about each over-cardinality metric name once: a label
189
+ // explosion is one problem, not one Error allocation per metric call.
190
+ const reportedNames = new Set();
188
191
  const metrics = new DefaultMetricsRegistry({
189
192
  ...(config.metrics ?? {}),
190
193
  onCardinalityLimit: (name, size) => {
191
194
  config.metrics?.onCardinalityLimit?.(name, size);
195
+ if (reportedNames.has(name) || reportedNames.size >= 1_024)
196
+ return;
197
+ reportedNames.add(name);
192
198
  config.onError?.(new Error(`Metric "${name}" exceeded the registry's series limit (${size}); ` +
193
199
  `check for a high-cardinality label`), "MetricsRegistry");
194
200
  },
@@ -4,4 +4,5 @@
4
4
  * Context propagation with AsyncLocalStorage for request-scoped IDs.
5
5
  */
6
6
  export { createPropagationContext, derivePropagationContext, getCurrentContext, requireCurrentContext, AsyncPropagationManager, createPropagationManager, } from "./propagation.core.js";
7
+ export { parseTraceparent, formatTraceparent, TRACEPARENT_HEADER, } from "./propagation.traceparent.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,5 @@
4
4
  * Context propagation with AsyncLocalStorage for request-scoped IDs.
5
5
  */
6
6
  export { createPropagationContext, derivePropagationContext, getCurrentContext, requireCurrentContext, AsyncPropagationManager, createPropagationManager, } from "./propagation.core.js";
7
+ export { parseTraceparent, formatTraceparent, TRACEPARENT_HEADER, } from "./propagation.traceparent.js";
7
8
  //# sourceMappingURL=index.js.map
@@ -5,7 +5,13 @@
5
5
  * trace, span, request, and correlation IDs.
6
6
  */
7
7
  import type { PropagationContext, PropagationContextOptions, PropagationManager } from "../types.js";
8
- /** Creates a new propagation context. */
8
+ /**
9
+ * Creates a new propagation context.
10
+ *
11
+ * Supplied trace and span IDs are validated: an invalid `traceId` or
12
+ * `parentSpanId` starts a fresh trace (and drops the untrusted sampling
13
+ * flags), and an invalid `spanId` is replaced.
14
+ */
9
15
  export declare function createPropagationContext(options?: PropagationContextOptions): PropagationContext;
10
16
  /**
11
17
  * Derives a child context from a parent.
@@ -5,19 +5,29 @@
5
5
  * trace, span, request, and correlation IDs.
6
6
  */
7
7
  import { AsyncLocalStorage } from "node:async_hooks";
8
- import { generateSpanId, generateTraceId } from "../internal/index.js";
8
+ import { generateSpanId, generateTraceId, isValidSpanId, isValidTraceId, } from "../internal/index.js";
9
9
  const storage = new AsyncLocalStorage();
10
- /** Creates a new propagation context. */
10
+ /**
11
+ * Creates a new propagation context.
12
+ *
13
+ * Supplied trace and span IDs are validated: an invalid `traceId` or
14
+ * `parentSpanId` starts a fresh trace (and drops the untrusted sampling
15
+ * flags), and an invalid `spanId` is replaced.
16
+ */
11
17
  export function createPropagationContext(options) {
18
+ const joins = (options?.traceId === undefined || isValidTraceId(options.traceId)) &&
19
+ (options?.parentSpanId === undefined ||
20
+ isValidSpanId(options.parentSpanId));
21
+ const spanId = options?.spanId;
12
22
  return {
13
- traceId: options?.traceId ?? generateTraceId(),
14
- spanId: options?.spanId ?? generateSpanId(),
15
- parentSpanId: options?.parentSpanId,
23
+ traceId: (joins ? options?.traceId : undefined) ?? generateTraceId(),
24
+ spanId: spanId !== undefined && isValidSpanId(spanId) ? spanId : generateSpanId(),
25
+ parentSpanId: joins ? options?.parentSpanId : undefined,
16
26
  requestId: options?.requestId,
17
27
  correlationId: options?.correlationId,
18
28
  userId: options?.userId,
19
29
  service: options?.service,
20
- traceFlags: options?.traceFlags,
30
+ traceFlags: joins ? options?.traceFlags : undefined,
21
31
  baggage: options?.baggage
22
32
  ? Object.freeze({ ...options.baggage })
23
33
  : undefined,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @zudojs/observability — W3C `traceparent` header
3
+ *
4
+ * Parses and formats the W3C Trace Context `traceparent` header, using the
5
+ * same ID validators as the span and propagation factories so an inbound
6
+ * header can never smuggle a malformed ID into a trace.
7
+ */
8
+ import type { SpanContext } from "../types.js";
9
+ /** Name of the W3C trace context header. */
10
+ export declare const TRACEPARENT_HEADER = "traceparent";
11
+ /**
12
+ * Parses a `traceparent` header value.
13
+ *
14
+ * Returns the remote span context (its `spanId` is the caller's span, to be
15
+ * used as `parent` when starting the local span), or `undefined` when the
16
+ * header is missing or malformed in any way — including an all-zero trace or
17
+ * span ID, uppercase hex, the forbidden version `ff`, or extra fields on
18
+ * version `00`.
19
+ */
20
+ export declare function parseTraceparent(header: string | readonly string[] | undefined | null): SpanContext | undefined;
21
+ /**
22
+ * Formats a span context as a version-`00` `traceparent` header value.
23
+ *
24
+ * Returns `undefined` when the context's trace or span ID is invalid, so a
25
+ * malformed context is never propagated downstream.
26
+ */
27
+ export declare function formatTraceparent(context: Pick<SpanContext, "traceId" | "spanId" | "traceFlags">): string | undefined;
28
+ //# sourceMappingURL=propagation.traceparent.d.ts.map
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @zudojs/observability — W3C `traceparent` header
3
+ *
4
+ * Parses and formats the W3C Trace Context `traceparent` header, using the
5
+ * same ID validators as the span and propagation factories so an inbound
6
+ * header can never smuggle a malformed ID into a trace.
7
+ */
8
+ import { isValidSpanId, isValidTraceId } from "../internal/index.js";
9
+ /** Name of the W3C trace context header. */
10
+ export const TRACEPARENT_HEADER = "traceparent";
11
+ const HEX2 = /^[0-9a-f]{2}$/;
12
+ /**
13
+ * Parses a `traceparent` header value.
14
+ *
15
+ * Returns the remote span context (its `spanId` is the caller's span, to be
16
+ * used as `parent` when starting the local span), or `undefined` when the
17
+ * header is missing or malformed in any way — including an all-zero trace or
18
+ * span ID, uppercase hex, the forbidden version `ff`, or extra fields on
19
+ * version `00`.
20
+ */
21
+ export function parseTraceparent(header) {
22
+ const value = Array.isArray(header) ? header[0] : header;
23
+ if (typeof value !== "string")
24
+ return undefined;
25
+ const parts = value.trim().split("-");
26
+ if (parts.length < 4)
27
+ return undefined;
28
+ const [version, traceId, spanId, flags] = parts;
29
+ if (!HEX2.test(version) || version === "ff")
30
+ return undefined;
31
+ if (version === "00" && parts.length !== 4)
32
+ return undefined;
33
+ if (!HEX2.test(flags))
34
+ return undefined;
35
+ if (!isValidTraceId(traceId) || !isValidSpanId(spanId))
36
+ return undefined;
37
+ return Object.freeze({
38
+ traceId,
39
+ spanId,
40
+ traceFlags: Number.parseInt(flags, 16),
41
+ });
42
+ }
43
+ /**
44
+ * Formats a span context as a version-`00` `traceparent` header value.
45
+ *
46
+ * Returns `undefined` when the context's trace or span ID is invalid, so a
47
+ * malformed context is never propagated downstream.
48
+ */
49
+ export function formatTraceparent(context) {
50
+ if (!isValidTraceId(context.traceId) || !isValidSpanId(context.spanId)) {
51
+ return undefined;
52
+ }
53
+ const flags = (context.traceFlags ?? 0) & 0xff;
54
+ return `00-${context.traceId}-${context.spanId}-${flags
55
+ .toString(16)
56
+ .padStart(2, "0")}`;
57
+ }
58
+ //# sourceMappingURL=propagation.traceparent.js.map
@@ -210,8 +210,14 @@ function walk(value, compiled, depth, seen) {
210
210
  const redacted = redactField(key, entry, compiled);
211
211
  // A field the matcher replaced is done — never walk into it, or a
212
212
  // nested object under a sensitive key would leak through its children.
213
- result[key] =
214
- redacted === entry ? walk(entry, compiled, depth + 1, seen) : redacted;
213
+ // defineProperty, not assignment: an own `__proto__` key (JSON.parse
214
+ // makes one) would otherwise replace the output's prototype.
215
+ Object.defineProperty(result, key, {
216
+ value: redacted === entry ? walk(entry, compiled, depth + 1, seen) : redacted,
217
+ enumerable: true,
218
+ writable: true,
219
+ configurable: true,
220
+ });
215
221
  }
216
222
  seen.delete(value);
217
223
  return result;
@@ -3,6 +3,6 @@
3
3
  *
4
4
  * Distributed tracing with spans, context, and exporters.
5
5
  */
6
- export { DefaultSpan, createSpan, createSpanContext, createChildSpanContext, isSampledContext, } from "./span/index.js";
7
- export { DefaultTracer, createTracer, type TracerOptions, } from "./tracer/index.js";
6
+ export { DefaultSpan, createSpan, createSpanContext, createChildSpanContext, isSampledContext, isValidSpanContext, } from "./span/index.js";
7
+ export { DefaultTracer, createTracer, withSpan, type TracerOptions, } from "./tracer/index.js";
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -3,6 +3,6 @@
3
3
  *
4
4
  * Distributed tracing with spans, context, and exporters.
5
5
  */
6
- export { DefaultSpan, createSpan, createSpanContext, createChildSpanContext, isSampledContext, } from "./span/index.js";
7
- export { DefaultTracer, createTracer, } from "./tracer/index.js";
6
+ export { DefaultSpan, createSpan, createSpanContext, createChildSpanContext, isSampledContext, isValidSpanContext, } from "./span/index.js";
7
+ export { DefaultTracer, createTracer, withSpan, } from "./tracer/index.js";
8
8
  //# sourceMappingURL=index.js.map
@@ -4,5 +4,5 @@
4
4
  * Span implementation and context creation.
5
5
  */
6
6
  export { DefaultSpan, createSpan } from "./span.core.js";
7
- export { createSpanContext, createChildSpanContext, isSampledContext, } from "./spanContext.type.js";
7
+ export { createSpanContext, createChildSpanContext, isSampledContext, isValidSpanContext, } from "./spanContext.type.js";
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,5 +4,5 @@
4
4
  * Span implementation and context creation.
5
5
  */
6
6
  export { DefaultSpan, createSpan } from "./span.core.js";
7
- export { createSpanContext, createChildSpanContext, isSampledContext, } from "./spanContext.type.js";
7
+ export { createSpanContext, createChildSpanContext, isSampledContext, isValidSpanContext, } from "./spanContext.type.js";
8
8
  //# sourceMappingURL=index.js.map
@@ -4,7 +4,21 @@
4
4
  * Factory for creating span context identifiers.
5
5
  */
6
6
  import type { SpanContext } from "../../types.js";
7
- /** Creates a new span context with cryptographically random IDs. */
7
+ /**
8
+ * True when `context` carries a valid W3C trace ID and span ID.
9
+ *
10
+ * A context that fails this check must never be adopted as a parent: its IDs
11
+ * usually come from an inbound header and would otherwise flow verbatim into
12
+ * every span, log record and exporter.
13
+ */
14
+ export declare function isValidSpanContext(context: Pick<SpanContext, "traceId" | "spanId"> | undefined): boolean;
15
+ /**
16
+ * Creates a new span context with cryptographically random IDs.
17
+ *
18
+ * Supplied IDs are validated. An invalid `traceId` or `parentSpanId` starts
19
+ * a fresh trace (new trace ID, no parent) instead of joining a trace the
20
+ * caller cannot vouch for; an invalid `spanId` is replaced with a new one.
21
+ */
8
22
  export declare function createSpanContext(options?: {
9
23
  readonly traceId?: string;
10
24
  readonly spanId?: string;
@@ -16,6 +30,8 @@ export declare function createSpanContext(options?: {
16
30
  *
17
31
  * The trace ID and the trace flags both carry over: dropping the flags is
18
32
  * what makes a parent-based sampler discard every child of a sampled trace.
33
+ * A parent with an invalid trace or span ID is not joined: the result is the
34
+ * root of a fresh trace.
19
35
  */
20
36
  export declare function createChildSpanContext(parent: SpanContext, overrides?: {
21
37
  readonly traceFlags?: number;
@@ -4,14 +4,39 @@
4
4
  * Factory for creating span context identifiers.
5
5
  */
6
6
  import { TraceFlags } from "../../types.js";
7
- import { generateSpanId, generateTraceId } from "../../internal/index.js";
8
- /** Creates a new span context with cryptographically random IDs. */
7
+ import { generateSpanId, generateTraceId, isValidSpanId, isValidTraceId, } from "../../internal/index.js";
8
+ /**
9
+ * True when `context` carries a valid W3C trace ID and span ID.
10
+ *
11
+ * A context that fails this check must never be adopted as a parent: its IDs
12
+ * usually come from an inbound header and would otherwise flow verbatim into
13
+ * every span, log record and exporter.
14
+ */
15
+ export function isValidSpanContext(context) {
16
+ if (context === undefined || context === null)
17
+ return false;
18
+ return (typeof context.traceId === "string" &&
19
+ typeof context.spanId === "string" &&
20
+ isValidTraceId(context.traceId) &&
21
+ isValidSpanId(context.spanId));
22
+ }
23
+ /**
24
+ * Creates a new span context with cryptographically random IDs.
25
+ *
26
+ * Supplied IDs are validated. An invalid `traceId` or `parentSpanId` starts
27
+ * a fresh trace (new trace ID, no parent) instead of joining a trace the
28
+ * caller cannot vouch for; an invalid `spanId` is replaced with a new one.
29
+ */
9
30
  export function createSpanContext(options) {
31
+ const traceOk = options?.traceId === undefined || isValidTraceId(options.traceId);
32
+ const parentOk = options?.parentSpanId === undefined || isValidSpanId(options.parentSpanId);
33
+ const joins = traceOk && parentOk;
34
+ const spanId = options?.spanId;
10
35
  return {
11
- traceId: options?.traceId ?? generateTraceId(),
12
- spanId: options?.spanId ?? generateSpanId(),
13
- parentSpanId: options?.parentSpanId,
14
- traceFlags: options?.traceFlags ?? TraceFlags.NONE,
36
+ traceId: (joins ? options?.traceId : undefined) ?? generateTraceId(),
37
+ spanId: spanId !== undefined && isValidSpanId(spanId) ? spanId : generateSpanId(),
38
+ parentSpanId: joins ? options?.parentSpanId : undefined,
39
+ traceFlags: (joins ? options?.traceFlags : undefined) ?? TraceFlags.NONE,
15
40
  };
16
41
  }
17
42
  /**
@@ -19,8 +44,13 @@ export function createSpanContext(options) {
19
44
  *
20
45
  * The trace ID and the trace flags both carry over: dropping the flags is
21
46
  * what makes a parent-based sampler discard every child of a sampled trace.
47
+ * A parent with an invalid trace or span ID is not joined: the result is the
48
+ * root of a fresh trace.
22
49
  */
23
50
  export function createChildSpanContext(parent, overrides) {
51
+ if (!isValidSpanContext(parent)) {
52
+ return createSpanContext({ traceFlags: overrides?.traceFlags });
53
+ }
24
54
  return createSpanContext({
25
55
  traceId: parent.traceId,
26
56
  parentSpanId: parent.spanId,
@@ -4,4 +4,5 @@
4
4
  * Span creation, sampling, and processor notification.
5
5
  */
6
6
  export { DefaultTracer, createTracer, type TracerOptions, } from "./tracer.core.js";
7
+ export { withSpan } from "./tracer.active.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,5 @@
4
4
  * Span creation, sampling, and processor notification.
5
5
  */
6
6
  export { DefaultTracer, createTracer, } from "./tracer.core.js";
7
+ export { withSpan } from "./tracer.active.js";
7
8
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @zudojs/observability — Active spans
3
+ *
4
+ * Runs a callback with a span as the active propagation context, so logs
5
+ * and child spans created inside it join the span's trace automatically.
6
+ */
7
+ import type { Span, SpanOptions, Tracer } from "../../types.js";
8
+ /**
9
+ * Starts a span, runs `fn` with it as the active context, and ends it.
10
+ *
11
+ * Inside `fn`, `getCurrentContext()` returns a context carrying the span's
12
+ * trace and span IDs (plus the request, correlation, user and baggage of
13
+ * the surrounding context), so log records are correlated with the span and
14
+ * spans started without an explicit parent become its children. The span
15
+ * ends when `fn` returns or, for a promise, when it settles; a throw or a
16
+ * rejection is recorded on the span and re-thrown.
17
+ */
18
+ export declare function withSpan<T>(tracer: Tracer, name: string, fn: (span: Span) => T, options?: SpanOptions): T;
19
+ //# sourceMappingURL=tracer.active.d.ts.map
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @zudojs/observability — Active spans
3
+ *
4
+ * Runs a callback with a span as the active propagation context, so logs
5
+ * and child spans created inside it join the span's trace automatically.
6
+ */
7
+ import { SpanStatus } from "../../types.js";
8
+ import { createPropagationContext, createPropagationManager, getCurrentContext, } from "../../propagation/index.js";
9
+ const manager = createPropagationManager();
10
+ function fail(span, error) {
11
+ const err = error instanceof Error ? error : new Error(String(error));
12
+ span.recordError(err);
13
+ span.setStatus(SpanStatus.ERROR, err.message);
14
+ span.end();
15
+ }
16
+ function isThenable(value) {
17
+ return (value !== null &&
18
+ (typeof value === "object" || typeof value === "function") &&
19
+ typeof value.then === "function");
20
+ }
21
+ /**
22
+ * Starts a span, runs `fn` with it as the active context, and ends it.
23
+ *
24
+ * Inside `fn`, `getCurrentContext()` returns a context carrying the span's
25
+ * trace and span IDs (plus the request, correlation, user and baggage of
26
+ * the surrounding context), so log records are correlated with the span and
27
+ * spans started without an explicit parent become its children. The span
28
+ * ends when `fn` returns or, for a promise, when it settles; a throw or a
29
+ * rejection is recorded on the span and re-thrown.
30
+ */
31
+ export function withSpan(tracer, name, fn, options) {
32
+ const span = tracer.startSpan(name, options);
33
+ const ambient = getCurrentContext();
34
+ const context = createPropagationContext({
35
+ requestId: ambient?.requestId,
36
+ correlationId: ambient?.correlationId,
37
+ userId: ambient?.userId,
38
+ service: ambient?.service,
39
+ baggage: ambient?.baggage,
40
+ traceId: span.context.traceId,
41
+ spanId: span.context.spanId,
42
+ parentSpanId: span.context.parentSpanId,
43
+ traceFlags: span.context.traceFlags,
44
+ });
45
+ return manager.runSync(context, () => {
46
+ let result;
47
+ try {
48
+ result = fn(span);
49
+ }
50
+ catch (error) {
51
+ fail(span, error);
52
+ throw error;
53
+ }
54
+ if (!isThenable(result)) {
55
+ span.end();
56
+ return result;
57
+ }
58
+ return Promise.resolve(result).then((value) => {
59
+ span.end();
60
+ return value;
61
+ }, (error) => {
62
+ fail(span, error);
63
+ throw error;
64
+ });
65
+ });
66
+ }
67
+ //# sourceMappingURL=tracer.active.js.map
@@ -43,6 +43,11 @@ export declare class DefaultTracer implements Tracer {
43
43
  private readonly onError?;
44
44
  constructor(options?: TracerOptions);
45
45
  startSpan(name: string, options?: SpanOptions): Span;
46
+ /**
47
+ * Starts a span and runs `fn` with it as the active context.
48
+ * @see withSpan
49
+ */
50
+ startActiveSpan<T>(name: string, fn: (span: Span) => T, options?: SpanOptions): T;
46
51
  private notifyEnd;
47
52
  /** Exports a completed span directly, bypassing the processors. */
48
53
  exportSpan(span: ReadableSpan): Promise<void>;
@@ -8,6 +8,8 @@ import { TraceFlags } from "../../types.js";
8
8
  import { DefaultSpan } from "../span/span.core.js";
9
9
  import { createChildSpanContext, createSpanContext, } from "../span/spanContext.type.js";
10
10
  import { AlwaysOnSampler } from "../../sampling/index.js";
11
+ import { resolveSpanParent } from "./tracer.parent.js";
12
+ import { withSpan } from "./tracer.active.js";
11
13
  /**
12
14
  * Default tracer that creates spans and notifies processors on start/end.
13
15
  */
@@ -31,12 +33,12 @@ export class DefaultTracer {
31
33
  this.onError = options?.onError;
32
34
  }
33
35
  startSpan(name, options) {
34
- // Build the context first so the sampler can key on the real trace ID,
35
- // then stamp the decision into traceFlags so children inherit it.
36
- const base = options?.parent
37
- ? createChildSpanContext(options.parent)
38
- : createSpanContext();
39
- const decision = this.sampler.shouldSample(options?.parent, base.traceId);
36
+ // Join the explicit parent, else the ambient propagation context. Build
37
+ // the context first so the sampler can key on the real trace ID, then
38
+ // stamp the decision into traceFlags so children inherit it.
39
+ const { parent, samplerParent } = resolveSpanParent(options?.parent);
40
+ const base = parent ? createChildSpanContext(parent) : createSpanContext();
41
+ const decision = this.sampler.shouldSample(samplerParent, base.traceId);
40
42
  const sampled = decision.decision === "RECORD_AND_SAMPLE";
41
43
  const recording = decision.decision !== "DO_NOT_RECORD";
42
44
  const context = {
@@ -71,6 +73,13 @@ export class DefaultTracer {
71
73
  }
72
74
  return span;
73
75
  }
76
+ /**
77
+ * Starts a span and runs `fn` with it as the active context.
78
+ * @see withSpan
79
+ */
80
+ startActiveSpan(name, fn, options) {
81
+ return withSpan(this, name, fn, options);
82
+ }
74
83
  notifyEnd(readable) {
75
84
  for (const processor of this.processors) {
76
85
  try {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @zudojs/observability — Tracer parent resolution
3
+ *
4
+ * Decides which context a new span joins: the explicit `parent` when it is
5
+ * valid, otherwise the ambient propagation context, otherwise none.
6
+ */
7
+ import type { SpanContext } from "../../types.js";
8
+ /** The parent a span joins, and the parent the sampler should consult. */
9
+ export interface ResolvedSpanParent {
10
+ /** Context the new span becomes a child of; `undefined` starts a trace. */
11
+ readonly parent?: SpanContext;
12
+ /**
13
+ * Context handed to the sampler. `undefined` when no upstream sampling
14
+ * decision exists, so the root sampler decides — an ambient context
15
+ * created without trace flags is not a "not sampled" parent.
16
+ */
17
+ readonly samplerParent?: SpanContext;
18
+ }
19
+ /**
20
+ * Resolves the parent for a new span.
21
+ *
22
+ * An explicit parent with invalid IDs is never joined (the span starts a
23
+ * fresh trace). Without an explicit parent, the span joins the active
24
+ * propagation context so logs and spans written in one request share one
25
+ * trace ID.
26
+ */
27
+ export declare function resolveSpanParent(explicit?: SpanContext): ResolvedSpanParent;
28
+ //# sourceMappingURL=tracer.parent.d.ts.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @zudojs/observability — Tracer parent resolution
3
+ *
4
+ * Decides which context a new span joins: the explicit `parent` when it is
5
+ * valid, otherwise the ambient propagation context, otherwise none.
6
+ */
7
+ import { getCurrentContext } from "../../propagation/index.js";
8
+ import { isValidSpanContext } from "../span/spanContext.type.js";
9
+ /**
10
+ * Resolves the parent for a new span.
11
+ *
12
+ * An explicit parent with invalid IDs is never joined (the span starts a
13
+ * fresh trace). Without an explicit parent, the span joins the active
14
+ * propagation context so logs and spans written in one request share one
15
+ * trace ID.
16
+ */
17
+ export function resolveSpanParent(explicit) {
18
+ if (explicit !== undefined) {
19
+ return isValidSpanContext(explicit)
20
+ ? { parent: explicit, samplerParent: explicit }
21
+ : {};
22
+ }
23
+ const ambient = getCurrentContext();
24
+ if (!ambient || !isValidSpanContext(ambient))
25
+ return {};
26
+ const parent = {
27
+ traceId: ambient.traceId,
28
+ spanId: ambient.spanId,
29
+ traceFlags: ambient.traceFlags,
30
+ };
31
+ return {
32
+ parent,
33
+ samplerParent: ambient.traceFlags === undefined ? undefined : parent,
34
+ };
35
+ }
36
+ //# sourceMappingURL=tracer.parent.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/observability",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Structured logging, metrics, tracing, context propagation, and exporters for Zudojs applications.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -26,7 +26,7 @@
26
26
  "!dist/.tsbuildinfo"
27
27
  ],
28
28
  "dependencies": {
29
- "@zudojs/errors": "1.0.1"
29
+ "@zudojs/errors": "1.1.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "typescript": "7.0.2",