@zudojs/observability 0.1.0 → 1.0.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -13
  3. package/dist/errors/index.d.ts +5 -0
  4. package/dist/errors/index.js +5 -0
  5. package/dist/errors/observabilityError.core.d.ts +32 -0
  6. package/dist/errors/observabilityError.core.js +56 -0
  7. package/dist/exporter/exporter.console.d.ts +51 -3
  8. package/dist/exporter/exporter.console.js +110 -23
  9. package/dist/exporter/index.d.ts +2 -2
  10. package/dist/exporter/index.js +2 -2
  11. package/dist/index.d.ts +17 -11
  12. package/dist/index.js +20 -12
  13. package/dist/internal/ids.core.d.ts +26 -0
  14. package/dist/internal/ids.core.js +51 -0
  15. package/dist/internal/index.d.ts +5 -0
  16. package/dist/internal/index.js +5 -0
  17. package/dist/logLevel/index.d.ts +1 -1
  18. package/dist/logLevel/index.js +1 -1
  19. package/dist/logLevel/logLevel.type.d.ts +8 -2
  20. package/dist/logLevel/logLevel.type.js +27 -20
  21. package/dist/logRecord/index.d.ts +1 -1
  22. package/dist/logRecord/index.js +1 -1
  23. package/dist/logRecord/logRecord.core.d.ts +15 -3
  24. package/dist/logRecord/logRecord.core.js +48 -26
  25. package/dist/logger/logger.core.d.ts +16 -8
  26. package/dist/logger/logger.core.js +54 -23
  27. package/dist/metrics/counter/counter.core.d.ts +4 -1
  28. package/dist/metrics/counter/counter.core.js +11 -3
  29. package/dist/metrics/gauge/gauge.core.d.ts +1 -0
  30. package/dist/metrics/gauge/gauge.core.js +9 -0
  31. package/dist/metrics/histogram/histogram.core.d.ts +22 -11
  32. package/dist/metrics/histogram/histogram.core.js +102 -7
  33. package/dist/metrics/histogram/index.d.ts +1 -1
  34. package/dist/metrics/histogram/index.js +1 -1
  35. package/dist/metrics/index.d.ts +4 -3
  36. package/dist/metrics/index.js +4 -3
  37. package/dist/metrics/metrics.reader.d.ts +39 -0
  38. package/dist/metrics/metrics.reader.js +81 -0
  39. package/dist/metrics/metrics.registry.d.ts +55 -4
  40. package/dist/metrics/metrics.registry.js +136 -49
  41. package/dist/noop/index.d.ts +1 -1
  42. package/dist/noop/index.js +1 -1
  43. package/dist/noop/noopObservability.core.d.ts +14 -2
  44. package/dist/noop/noopObservability.core.js +48 -11
  45. package/dist/observability/observability.core.d.ts +57 -3
  46. package/dist/observability/observability.core.js +206 -50
  47. package/dist/processor/index.d.ts +4 -3
  48. package/dist/processor/index.js +4 -3
  49. package/dist/processor/processor.batch.d.ts +66 -11
  50. package/dist/processor/processor.batch.js +130 -29
  51. package/dist/processor/processor.log.d.ts +56 -0
  52. package/dist/processor/processor.log.js +119 -0
  53. package/dist/propagation/index.d.ts +1 -1
  54. package/dist/propagation/index.js +1 -1
  55. package/dist/propagation/propagation.core.d.ts +26 -6
  56. package/dist/propagation/propagation.core.js +35 -19
  57. package/dist/redaction/index.d.ts +1 -1
  58. package/dist/redaction/index.js +1 -1
  59. package/dist/redaction/redaction.core.d.ts +37 -5
  60. package/dist/redaction/redaction.core.js +180 -36
  61. package/dist/sampling/index.d.ts +1 -1
  62. package/dist/sampling/index.js +1 -1
  63. package/dist/sampling/sampler.type.d.ts +38 -6
  64. package/dist/sampling/sampler.type.js +64 -23
  65. package/dist/tracing/index.d.ts +2 -2
  66. package/dist/tracing/index.js +2 -2
  67. package/dist/tracing/span/index.d.ts +1 -1
  68. package/dist/tracing/span/index.js +1 -1
  69. package/dist/tracing/span/span.core.d.ts +51 -8
  70. package/dist/tracing/span/span.core.js +90 -10
  71. package/dist/tracing/span/spanContext.type.d.ts +12 -3
  72. package/dist/tracing/span/spanContext.type.js +19 -16
  73. package/dist/tracing/tracer/index.d.ts +2 -2
  74. package/dist/tracing/tracer/index.js +2 -2
  75. package/dist/tracing/tracer/tracer.core.d.ts +44 -14
  76. package/dist/tracing/tracer/tracer.core.js +81 -25
  77. package/dist/types/config.types.d.ts +138 -0
  78. package/dist/types/config.types.js +5 -0
  79. package/dist/types/logging.types.d.ts +94 -0
  80. package/dist/types/logging.types.js +20 -0
  81. package/dist/types/metrics.types.d.ts +93 -0
  82. package/dist/types/metrics.types.js +5 -0
  83. package/dist/types/tracing.types.d.ts +115 -0
  84. package/dist/types/tracing.types.js +28 -0
  85. package/package.json +23 -14
  86. package/dist/exporter/exporter.console.d.ts.map +0 -1
  87. package/dist/exporter/exporter.console.js.map +0 -1
  88. package/dist/exporter/index.d.ts.map +0 -1
  89. package/dist/exporter/index.js.map +0 -1
  90. package/dist/index.d.ts.map +0 -1
  91. package/dist/index.js.map +0 -1
  92. package/dist/logLevel/index.d.ts.map +0 -1
  93. package/dist/logLevel/index.js.map +0 -1
  94. package/dist/logLevel/logLevel.type.d.ts.map +0 -1
  95. package/dist/logLevel/logLevel.type.js.map +0 -1
  96. package/dist/logRecord/index.d.ts.map +0 -1
  97. package/dist/logRecord/index.js.map +0 -1
  98. package/dist/logRecord/logRecord.core.d.ts.map +0 -1
  99. package/dist/logRecord/logRecord.core.js.map +0 -1
  100. package/dist/logger/index.d.ts.map +0 -1
  101. package/dist/logger/index.js.map +0 -1
  102. package/dist/logger/logger.core.d.ts.map +0 -1
  103. package/dist/logger/logger.core.js.map +0 -1
  104. package/dist/metrics/counter/counter.core.d.ts.map +0 -1
  105. package/dist/metrics/counter/counter.core.js.map +0 -1
  106. package/dist/metrics/counter/index.d.ts.map +0 -1
  107. package/dist/metrics/counter/index.js.map +0 -1
  108. package/dist/metrics/gauge/gauge.core.d.ts.map +0 -1
  109. package/dist/metrics/gauge/gauge.core.js.map +0 -1
  110. package/dist/metrics/gauge/index.d.ts.map +0 -1
  111. package/dist/metrics/gauge/index.js.map +0 -1
  112. package/dist/metrics/histogram/histogram.core.d.ts.map +0 -1
  113. package/dist/metrics/histogram/histogram.core.js.map +0 -1
  114. package/dist/metrics/histogram/index.d.ts.map +0 -1
  115. package/dist/metrics/histogram/index.js.map +0 -1
  116. package/dist/metrics/index.d.ts.map +0 -1
  117. package/dist/metrics/index.js.map +0 -1
  118. package/dist/metrics/metrics.registry.d.ts.map +0 -1
  119. package/dist/metrics/metrics.registry.js.map +0 -1
  120. package/dist/noop/index.d.ts.map +0 -1
  121. package/dist/noop/index.js.map +0 -1
  122. package/dist/noop/noopObservability.core.d.ts.map +0 -1
  123. package/dist/noop/noopObservability.core.js.map +0 -1
  124. package/dist/observability/index.d.ts.map +0 -1
  125. package/dist/observability/index.js.map +0 -1
  126. package/dist/observability/observability.core.d.ts.map +0 -1
  127. package/dist/observability/observability.core.js.map +0 -1
  128. package/dist/processor/index.d.ts.map +0 -1
  129. package/dist/processor/index.js.map +0 -1
  130. package/dist/processor/processor.batch.d.ts.map +0 -1
  131. package/dist/processor/processor.batch.js.map +0 -1
  132. package/dist/propagation/index.d.ts.map +0 -1
  133. package/dist/propagation/index.js.map +0 -1
  134. package/dist/propagation/propagation.core.d.ts.map +0 -1
  135. package/dist/propagation/propagation.core.js.map +0 -1
  136. package/dist/redaction/index.d.ts.map +0 -1
  137. package/dist/redaction/index.js.map +0 -1
  138. package/dist/redaction/redaction.core.d.ts.map +0 -1
  139. package/dist/redaction/redaction.core.js.map +0 -1
  140. package/dist/sampling/index.d.ts.map +0 -1
  141. package/dist/sampling/index.js.map +0 -1
  142. package/dist/sampling/sampler.type.d.ts.map +0 -1
  143. package/dist/sampling/sampler.type.js.map +0 -1
  144. package/dist/tracing/index.d.ts.map +0 -1
  145. package/dist/tracing/index.js.map +0 -1
  146. package/dist/tracing/span/index.d.ts.map +0 -1
  147. package/dist/tracing/span/index.js.map +0 -1
  148. package/dist/tracing/span/span.core.d.ts.map +0 -1
  149. package/dist/tracing/span/span.core.js.map +0 -1
  150. package/dist/tracing/span/spanContext.type.d.ts.map +0 -1
  151. package/dist/tracing/span/spanContext.type.js.map +0 -1
  152. package/dist/tracing/tracer/index.d.ts.map +0 -1
  153. package/dist/tracing/tracer/index.js.map +0 -1
  154. package/dist/tracing/tracer/tracer.core.d.ts.map +0 -1
  155. package/dist/tracing/tracer/tracer.core.js.map +0 -1
  156. package/dist/types.d.ts.map +0 -1
  157. package/dist/types.js.map +0 -1
@@ -7,11 +7,13 @@
7
7
  */
8
8
  import { LogLevel } from "../types.js";
9
9
  import { StructuredLogger } from "../logger/index.js";
10
- import { DefaultMetricsRegistry } from "../metrics/index.js";
10
+ import { DefaultMetricsRegistry, PeriodicMetricReader, } from "../metrics/index.js";
11
11
  import { DefaultTracer } from "../tracing/index.js";
12
12
  import { AsyncPropagationManager } from "../propagation/index.js";
13
- import { ConsoleLogExporter, ConsoleSpanExporter, ConsoleMetricExporter, } from "../exporter/index.js";
14
- import { BatchSpanProcessor } from "../processor/index.js";
13
+ import { ConsoleLogExporter, ConsoleSpanExporter, ConsoleMetricExporter, noopLogExporter, noopMetricExporter, } from "../exporter/index.js";
14
+ import { BatchLogProcessor, BatchSpanProcessor, noopSpanExporter, } from "../processor/index.js";
15
+ import { createRedactor, createStructureRedactor } from "../redaction/index.js";
16
+ import { AlwaysOnSampler } from "../sampling/index.js";
15
17
  /**
16
18
  * Default observability implementation.
17
19
  *
@@ -23,66 +25,220 @@ export class DefaultObservability {
23
25
  metrics;
24
26
  tracer;
25
27
  propagation;
28
+ pipeline;
26
29
  resourceAttributes;
27
- shutdownHooks = [];
28
- constructor(config) {
29
- this.resourceAttributes = {
30
- "service.name": config.serviceName,
31
- ...(config.serviceVersion
32
- ? { "service.version": config.serviceVersion }
33
- : {}),
34
- ...(config.environment
35
- ? { "deployment.environment": config.environment }
36
- : {}),
37
- ...(config.resource ?? {}),
38
- };
39
- // Logger — uses console by default, or the provided exporter
40
- const logExporter = config.logExporter ?? new ConsoleLogExporter();
41
- this.logger = new StructuredLogger({
42
- name: config.serviceName,
43
- level: config.logLevel ?? LogLevel.INFO,
44
- transport: {
45
- name: "observability",
46
- write: (record) => {
47
- void logExporter.export([record]);
48
- },
49
- },
50
- });
51
- // Metrics
52
- this.metrics = new DefaultMetricsRegistry();
53
- // Tracing
54
- const spanExporter = config.spanExporter ?? new ConsoleSpanExporter();
55
- const processor = new BatchSpanProcessor({ exporter: spanExporter });
30
+ /** Scopes share the parent's pipeline, so only the root tears it down. */
31
+ ownsPipeline;
32
+ shutdownPromise;
33
+ constructor(config, scope) {
34
+ this.resourceAttributes =
35
+ scope?.resourceAttributes ?? buildResourceAttributes(config);
36
+ if (scope) {
37
+ this.pipeline = scope.pipeline;
38
+ this.ownsPipeline = false;
39
+ }
40
+ else {
41
+ this.pipeline = buildPipeline(config);
42
+ this.ownsPipeline = true;
43
+ }
44
+ this.logger = this.pipeline.logger;
45
+ this.metrics = this.pipeline.metrics;
46
+ this.propagation = this.pipeline.propagation;
47
+ // The tracer is the one piece that differs per scope, because the
48
+ // resource attributes it stamps onto spans are the scope.
56
49
  this.tracer = new DefaultTracer({
57
- processors: config.processors ?? [processor],
58
- exporter: spanExporter,
50
+ processors: this.pipeline.processors,
59
51
  resource: this.resourceAttributes,
60
- });
61
- // Propagation
62
- this.propagation = new AsyncPropagationManager();
63
- // Register shutdown hooks
64
- this.shutdownHooks.push(async () => {
65
- await processor.shutdown();
66
- await spanExporter.shutdown();
67
- await logExporter.shutdown();
52
+ sampler: this.pipeline.sampler ?? new AlwaysOnSampler(),
53
+ limits: this.pipeline.config.spanLimits,
54
+ captureStackTraces: this.pipeline.config.captureStackTraces,
55
+ redactAttribute: this.pipeline.redactAttribute,
56
+ onError: this.pipeline.config.onError,
68
57
  });
69
58
  }
59
+ /**
60
+ * Creates a scope that differs only in its resource attributes.
61
+ *
62
+ * The logger, registry, processors and exporters are shared with the parent:
63
+ * building a second pipeline here silently dropped every configured
64
+ * exporter and log level, and left an orphan flush timer nobody shut down.
65
+ */
70
66
  resource(attributes) {
71
- const child = new DefaultObservability({
72
- serviceName: this.resourceAttributes["service.name"] ?? "unknown",
73
- resource: { ...this.resourceAttributes, ...attributes },
67
+ return new DefaultObservability(this.pipeline.config, {
68
+ pipeline: this.pipeline,
69
+ resourceAttributes: { ...this.resourceAttributes, ...attributes },
74
70
  });
75
- return child;
76
71
  }
72
+ /**
73
+ * Drains every buffer without shutting anything down.
74
+ *
75
+ * A scope shares its parent's pipeline, so draining from one is both safe
76
+ * and what the caller asked for. Returning early because the scope does not
77
+ * *own* the pipeline made `obs.resource({...}).flush()` a silent no-op —
78
+ * the buffered records it was meant to push were still sitting in the queue
79
+ * when the caller went on to exit.
80
+ */
81
+ async flush() {
82
+ await this.drain();
83
+ }
84
+ async drain() {
85
+ const tasks = [];
86
+ if (this.pipeline.logProcessor) {
87
+ tasks.push(this.pipeline.logProcessor.flush());
88
+ }
89
+ for (const processor of this.pipeline.processors) {
90
+ if (processor.forceFlush)
91
+ tasks.push(processor.forceFlush());
92
+ }
93
+ tasks.push(this.pipeline.metricReader.collect());
94
+ await this.reportFailures(await Promise.allSettled(tasks), "flush");
95
+ }
96
+ async reportFailures(results, source) {
97
+ for (const result of results) {
98
+ if (result.status === "rejected") {
99
+ this.pipeline.config.onError?.(result.reason, source);
100
+ }
101
+ }
102
+ }
103
+ /**
104
+ * Shuts the pipeline down. Idempotent, and one failing step never skips the
105
+ * rest — a half-torn-down telemetry stack is worse than a noisy one.
106
+ *
107
+ * A scope created by {@link DefaultObservability.resource} does not own the
108
+ * pipeline and shutting it down is a no-op; shut down the root instead.
109
+ */
77
110
  async shutdown() {
78
- for (const hook of this.shutdownHooks) {
79
- await hook();
111
+ if (!this.ownsPipeline)
112
+ return;
113
+ this.shutdownPromise ??= this.performShutdown();
114
+ return this.shutdownPromise;
115
+ }
116
+ async performShutdown() {
117
+ // Drain first: whatever is still queued should reach the backend before
118
+ // the exporters close.
119
+ await this.drain();
120
+ const steps = [];
121
+ steps.push(this.pipeline.metricReader.shutdown());
122
+ if (this.pipeline.logProcessor) {
123
+ steps.push(this.pipeline.logProcessor.shutdown());
80
124
  }
81
- if (this.tracer instanceof DefaultTracer) {
82
- await this.tracer.shutdown();
125
+ for (const processor of this.pipeline.processors) {
126
+ steps.push(processor.shutdown());
83
127
  }
128
+ // Every exporter this facade created is owned by exactly one processor or
129
+ // reader, which closes it in the step above; an exporter supplied by the
130
+ // caller is therefore never closed twice.
131
+ await this.reportFailures(await Promise.allSettled(steps), "shutdown");
84
132
  }
85
133
  }
134
+ function buildResourceAttributes(config) {
135
+ return {
136
+ "service.name": config.serviceName,
137
+ ...(config.serviceVersion
138
+ ? { "service.version": config.serviceVersion }
139
+ : {}),
140
+ ...(config.environment
141
+ ? { "deployment.environment": config.environment }
142
+ : {}),
143
+ ...(config.resource ?? {}),
144
+ };
145
+ }
146
+ function buildPipeline(config) {
147
+ const useConsole = config.useConsoleExporters ?? true;
148
+ /* ── Logging ─────────────────────────────────────────────────────────── */
149
+ const logExporter = config.logExporter ??
150
+ (useConsole ? new ConsoleLogExporter() : noopLogExporter);
151
+ const logProcessor = new BatchLogProcessor({
152
+ exporter: logExporter,
153
+ batchSize: config.logBatchSize,
154
+ flushIntervalMs: config.logFlushIntervalMs,
155
+ onError: config.onError,
156
+ onDrop: (dropped) => config.onError?.(new Error(`Dropped ${dropped} log records: queue full`), "BatchLogProcessor"),
157
+ });
158
+ // Redaction is applied by the logger, so every transport and exporter
159
+ // downstream sees redacted records — configuring it and not wiring it here
160
+ // is what made the "credentials are never logged" promise untrue.
161
+ const redactor = config.redaction
162
+ ? createStructureRedactor(config.redaction)
163
+ : undefined;
164
+ const logger = new StructuredLogger({
165
+ name: config.serviceName,
166
+ level: config.logLevel ?? LogLevel.INFO,
167
+ transport: logProcessor,
168
+ redact: redactor
169
+ ? (context) => redactor(context)
170
+ : undefined,
171
+ });
172
+ /* ── Tracing ─────────────────────────────────────────────────────────── */
173
+ // Built only when this facade owns the processor: constructing an exporter
174
+ // the caller's own processors will never touch is waste at best and a
175
+ // second, unclosed handle at worst.
176
+ const ownSpanExporter = () => config.spanExporter ??
177
+ (useConsole ? new ConsoleSpanExporter() : noopSpanExporter);
178
+ const processors = config.processors ?? [
179
+ new BatchSpanProcessor({
180
+ exporter: ownSpanExporter(),
181
+ onError: config.onError,
182
+ onDrop: (dropped) => config.onError?.(new Error(`Dropped ${dropped} spans: queue full`), "BatchSpanProcessor"),
183
+ }),
184
+ ];
185
+ // When the caller supplied their own processors, they own the exporter's
186
+ // lifecycle too; otherwise our processor closes it on shutdown.
187
+ /* ── Metrics ─────────────────────────────────────────────────────────── */
188
+ const metrics = new DefaultMetricsRegistry({
189
+ ...(config.metrics ?? {}),
190
+ onCardinalityLimit: (name, size) => {
191
+ config.metrics?.onCardinalityLimit?.(name, size);
192
+ config.onError?.(new Error(`Metric "${name}" exceeded the registry's series limit (${size}); ` +
193
+ `check for a high-cardinality label`), "MetricsRegistry");
194
+ },
195
+ });
196
+ const metricExporter = config.metricExporter ??
197
+ (useConsole ? new ConsoleMetricExporter() : undefined);
198
+ // The reader is built whenever there is anything to export to, even at
199
+ // interval 0. `start()` is a no-op at 0, so periodic export stays disabled
200
+ // as documented — but `flush()` and `shutdown()` still collect a final
201
+ // snapshot and close the exporter. Skipping the reader entirely left a
202
+ // console exporter this facade had constructed running to the end of the
203
+ // process with no metric ever leaving it.
204
+ const metricIntervalMs = config.metricExportIntervalMs ?? 60_000;
205
+ const metricReader = new PeriodicMetricReader({
206
+ registry: metrics,
207
+ exporter: metricExporter ?? noopMetricExporter,
208
+ intervalMs: metricIntervalMs,
209
+ onError: config.onError,
210
+ });
211
+ metricReader.start();
212
+ const redactAttribute = config.redaction
213
+ ? buildAttributeRedactor(config.redaction)
214
+ : undefined;
215
+ return {
216
+ logger,
217
+ metrics,
218
+ propagation: new AsyncPropagationManager(),
219
+ processors,
220
+ logProcessor,
221
+ metricReader,
222
+ config,
223
+ sampler: config.sampler,
224
+ redactAttribute,
225
+ };
226
+ }
227
+ /**
228
+ * Builds the span-attribute redactor.
229
+ *
230
+ * A sensitive key replaces its whole value; anything else is still walked, so
231
+ * a token nested inside an otherwise innocuous `request` attribute is caught
232
+ * too.
233
+ */
234
+ function buildAttributeRedactor(redaction) {
235
+ const leaf = createRedactor(redaction);
236
+ const deep = createStructureRedactor(redaction);
237
+ return (key, value) => {
238
+ const replaced = leaf(key, value);
239
+ return replaced === value ? deep(value) : replaced;
240
+ };
241
+ }
86
242
  /** Creates an observability instance. */
87
243
  export function createObservability(config) {
88
244
  return new DefaultObservability(config);
@@ -1,7 +1,8 @@
1
1
  /**
2
- * @zudojs/observability — Processor
2
+ * @zudojs/observability — Processors
3
3
  *
4
- * Batch span processor for accumulating and exporting spans.
4
+ * Batching and simple processors for spans, and a batching log transport.
5
5
  */
6
- export { BatchSpanProcessor, createBatchSpanProcessor, } from "./processor.batch.js";
6
+ export { BatchSpanProcessor, createBatchSpanProcessor, SimpleSpanProcessor, createSimpleSpanProcessor, noopSpanExporter, type BatchSpanProcessorOptions, } from "./processor.batch.js";
7
+ export { BatchLogProcessor, createBatchLogProcessor, type BatchLogProcessorOptions, } from "./processor.log.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -1,7 +1,8 @@
1
1
  /**
2
- * @zudojs/observability — Processor
2
+ * @zudojs/observability — Processors
3
3
  *
4
- * Batch span processor for accumulating and exporting spans.
4
+ * Batching and simple processors for spans, and a batching log transport.
5
5
  */
6
- export { BatchSpanProcessor, createBatchSpanProcessor, } from "./processor.batch.js";
6
+ export { BatchSpanProcessor, createBatchSpanProcessor, SimpleSpanProcessor, createSimpleSpanProcessor, noopSpanExporter, } from "./processor.batch.js";
7
+ export { BatchLogProcessor, createBatchLogProcessor, } from "./processor.log.js";
7
8
  //# sourceMappingURL=index.js.map
@@ -2,9 +2,27 @@
2
2
  * @zudojs/observability — Processor
3
3
  *
4
4
  * Batch span processor that accumulates spans and exports them periodically.
5
- * Memory-bounded with configurable batch size and flush interval.
5
+ * Genuinely memory-bounded: the queue has a hard cap, and spans past it are
6
+ * dropped and counted rather than growing the process until it dies.
6
7
  */
7
8
  import type { ReadableSpan, Span, SpanExporter, SpanProcessor } from "../types.js";
9
+ /** A span exporter that discards everything, for tests and disabled tracing. */
10
+ export declare const noopSpanExporter: SpanExporter;
11
+ /** Options for {@link BatchSpanProcessor}. */
12
+ export interface BatchSpanProcessorOptions {
13
+ /** Where completed spans go. Required — a processor with no exporter is a leak with extra steps. */
14
+ readonly exporter: SpanExporter;
15
+ /** Spans per export call. Default: 512. */
16
+ readonly batchSize?: number;
17
+ /** How often the queue is drained, in ms. Default: 5,000. */
18
+ readonly flushIntervalMs?: number;
19
+ /** Hard cap on queued spans. Default: 2,048. */
20
+ readonly maxQueueSize?: number;
21
+ /** Reports export failures and dropped spans. */
22
+ readonly onError?: (error: unknown, source: string) => void;
23
+ /** Called when spans are dropped because the queue was full. */
24
+ readonly onDrop?: (droppedCount: number) => void;
25
+ }
8
26
  /**
9
27
  * Collects completed spans and exports them in batches.
10
28
  * Flushes on batch size or interval, whichever comes first.
@@ -13,23 +31,60 @@ export declare class BatchSpanProcessor implements SpanProcessor {
13
31
  private readonly exporter;
14
32
  private readonly batchSize;
15
33
  private readonly flushIntervalMs;
34
+ private readonly maxQueueSize;
35
+ private readonly onError?;
36
+ private readonly onDrop?;
16
37
  private buffer;
17
38
  private timer?;
18
39
  private shuttingDown;
19
- constructor(options?: {
20
- readonly exporter?: SpanExporter;
21
- readonly batchSize?: number;
22
- readonly flushIntervalMs?: number;
23
- });
40
+ private inFlight?;
41
+ private droppedSpans;
42
+ constructor(options: BatchSpanProcessorOptions);
24
43
  onStart(_span: Span): void;
25
44
  onEnd(span: ReadableSpan): void;
45
+ private startTimer;
46
+ private stopTimer;
47
+ /** Number of spans dropped because the queue was full. */
48
+ getDroppedCount(): number;
49
+ /** Spans currently queued. */
50
+ getQueueSize(): number;
51
+ /**
52
+ * Exports everything queued.
53
+ *
54
+ * Concurrent calls are serialised: two overlapping exports against the same
55
+ * backend is a good way to double-deliver a batch or exhaust a connection
56
+ * pool.
57
+ */
26
58
  flush(): Promise<void>;
59
+ private drain;
60
+ /** Drains the queue without shutting the processor down. */
61
+ forceFlush(): Promise<void>;
27
62
  shutdown(): Promise<void>;
28
63
  }
29
64
  /** Creates a batch span processor. */
30
- export declare function createBatchSpanProcessor(options?: {
31
- readonly exporter?: SpanExporter;
32
- readonly batchSize?: number;
33
- readonly flushIntervalMs?: number;
34
- }): BatchSpanProcessor;
65
+ export declare function createBatchSpanProcessor(options: BatchSpanProcessorOptions): BatchSpanProcessor;
66
+ /**
67
+ * A processor that exports each span as it ends.
68
+ *
69
+ * Simple and immediate; use it in tests and short-lived processes, and the
70
+ * batch processor everywhere else.
71
+ */
72
+ export declare class SimpleSpanProcessor implements SpanProcessor {
73
+ private readonly exporter;
74
+ private readonly onError?;
75
+ private pending;
76
+ constructor(options: {
77
+ readonly exporter: SpanExporter;
78
+ readonly onError?: (error: unknown, source: string) => void;
79
+ });
80
+ onStart(_span: Span): void;
81
+ onEnd(span: ReadableSpan): void;
82
+ forceFlush(): Promise<void>;
83
+ shutdown(): Promise<void>;
84
+ }
85
+ /** Creates a simple, unbatched span processor. */
86
+ export declare function createSimpleSpanProcessor(options: {
87
+ readonly exporter: SpanExporter;
88
+ readonly onError?: (error: unknown, source: string) => void;
89
+ }): SimpleSpanProcessor;
35
90
  //# sourceMappingURL=processor.batch.d.ts.map
@@ -2,10 +2,17 @@
2
2
  * @zudojs/observability — Processor
3
3
  *
4
4
  * Batch span processor that accumulates spans and exports them periodically.
5
- * Memory-bounded with configurable batch size and flush interval.
5
+ * Genuinely memory-bounded: the queue has a hard cap, and spans past it are
6
+ * dropped and counted rather than growing the process until it dies.
6
7
  */
7
8
  const DEFAULT_BATCH_SIZE = 512;
8
9
  const DEFAULT_FLUSH_INTERVAL_MS = 5_000;
10
+ const DEFAULT_MAX_QUEUE_SIZE = 2_048;
11
+ /** A span exporter that discards everything, for tests and disabled tracing. */
12
+ export const noopSpanExporter = {
13
+ export: async () => { },
14
+ shutdown: async () => { },
15
+ };
9
16
  /**
10
17
  * Collects completed spans and exports them in batches.
11
18
  * Flushes on batch size or interval, whichever comes first.
@@ -14,64 +21,158 @@ export class BatchSpanProcessor {
14
21
  exporter;
15
22
  batchSize;
16
23
  flushIntervalMs;
24
+ maxQueueSize;
25
+ onError;
26
+ onDrop;
17
27
  buffer = [];
18
28
  timer;
19
29
  shuttingDown = false;
30
+ inFlight;
31
+ droppedSpans = 0;
20
32
  constructor(options) {
21
- this.exporter = options?.exporter ?? {
22
- export: async () => { },
23
- shutdown: async () => { },
24
- };
25
- this.batchSize = options?.batchSize ?? DEFAULT_BATCH_SIZE;
26
- this.flushIntervalMs =
27
- options?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
33
+ this.exporter = options.exporter;
34
+ this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
35
+ this.flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
36
+ this.maxQueueSize = Math.max(options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE, this.batchSize);
37
+ this.onError = options.onError;
38
+ this.onDrop = options.onDrop;
28
39
  }
29
40
  onStart(_span) {
30
- // No-op: we only care about completed spans
41
+ // No-op: we only care about completed spans.
31
42
  }
32
43
  onEnd(span) {
33
44
  if (this.shuttingDown)
34
45
  return;
46
+ if (this.buffer.length >= this.maxQueueSize) {
47
+ this.droppedSpans++;
48
+ this.onDrop?.(this.droppedSpans);
49
+ return;
50
+ }
35
51
  this.buffer.push(span);
36
52
  if (this.buffer.length >= this.batchSize) {
37
53
  void this.flush();
38
54
  return;
39
55
  }
40
- if (!this.timer) {
41
- this.timer = setInterval(() => {
42
- void this.flush();
43
- }, this.flushIntervalMs);
44
- // Allow the process to exit even if the timer is active
45
- if (this.timer &&
46
- typeof this.timer === "object" &&
47
- "unref" in this.timer) {
48
- this.timer.unref();
56
+ this.startTimer();
57
+ }
58
+ startTimer() {
59
+ if (this.timer !== undefined || this.shuttingDown)
60
+ return;
61
+ this.timer = setInterval(() => {
62
+ if (this.buffer.length === 0) {
63
+ // Nothing left to drain: stop waking the event loop until the next
64
+ // span arrives, rather than ticking forever for the process lifetime.
65
+ this.stopTimer();
66
+ return;
49
67
  }
68
+ void this.flush();
69
+ }, this.flushIntervalMs);
70
+ // Allow the process to exit even if the timer is active.
71
+ if (typeof this.timer === "object" && "unref" in this.timer) {
72
+ this.timer.unref();
73
+ }
74
+ }
75
+ stopTimer() {
76
+ if (this.timer !== undefined) {
77
+ clearInterval(this.timer);
78
+ this.timer = undefined;
50
79
  }
51
80
  }
81
+ /** Number of spans dropped because the queue was full. */
82
+ getDroppedCount() {
83
+ return this.droppedSpans;
84
+ }
85
+ /** Spans currently queued. */
86
+ getQueueSize() {
87
+ return this.buffer.length;
88
+ }
89
+ /**
90
+ * Exports everything queued.
91
+ *
92
+ * Concurrent calls are serialised: two overlapping exports against the same
93
+ * backend is a good way to double-deliver a batch or exhaust a connection
94
+ * pool.
95
+ */
52
96
  async flush() {
53
- if (this.buffer.length === 0)
54
- return;
55
- const batch = [...this.buffer];
56
- this.buffer = [];
97
+ if (this.inFlight) {
98
+ await this.inFlight;
99
+ if (this.buffer.length === 0)
100
+ return;
101
+ }
102
+ const run = this.drain();
103
+ this.inFlight = run;
57
104
  try {
58
- await this.exporter.export(batch);
105
+ await run;
59
106
  }
60
- catch {
61
- // Telemetry failure should not bring down the application
107
+ finally {
108
+ if (this.inFlight === run)
109
+ this.inFlight = undefined;
62
110
  }
63
111
  }
112
+ async drain() {
113
+ while (this.buffer.length > 0) {
114
+ const batch = this.buffer.splice(0, this.batchSize);
115
+ try {
116
+ await this.exporter.export(batch);
117
+ }
118
+ catch (error) {
119
+ // Telemetry failure must not bring down the application — but it must
120
+ // not be invisible either.
121
+ this.onError?.(error, "SpanExporter.export");
122
+ }
123
+ }
124
+ }
125
+ /** Drains the queue without shutting the processor down. */
126
+ async forceFlush() {
127
+ await this.flush();
128
+ }
64
129
  async shutdown() {
65
130
  this.shuttingDown = true;
66
- if (this.timer) {
67
- clearInterval(this.timer);
68
- this.timer = undefined;
69
- }
131
+ this.stopTimer();
70
132
  await this.flush();
133
+ await this.exporter.shutdown();
71
134
  }
72
135
  }
73
136
  /** Creates a batch span processor. */
74
137
  export function createBatchSpanProcessor(options) {
75
138
  return new BatchSpanProcessor(options);
76
139
  }
140
+ /**
141
+ * A processor that exports each span as it ends.
142
+ *
143
+ * Simple and immediate; use it in tests and short-lived processes, and the
144
+ * batch processor everywhere else.
145
+ */
146
+ export class SimpleSpanProcessor {
147
+ exporter;
148
+ onError;
149
+ pending = new Set();
150
+ constructor(options) {
151
+ this.exporter = options.exporter;
152
+ this.onError = options.onError;
153
+ }
154
+ onStart(_span) { }
155
+ onEnd(span) {
156
+ const task = this.exporter
157
+ .export([span])
158
+ .catch((error) => {
159
+ this.onError?.(error, "SpanExporter.export");
160
+ })
161
+ .finally(() => {
162
+ this.pending.delete(task);
163
+ });
164
+ this.pending.add(task);
165
+ }
166
+ async forceFlush() {
167
+ await Promise.allSettled([...this.pending]);
168
+ }
169
+ async shutdown() {
170
+ await this.forceFlush();
171
+ await this.exporter.shutdown();
172
+ }
173
+ }
174
+ /** Creates a simple, unbatched span processor. */
175
+ export function createSimpleSpanProcessor(options) {
176
+ return new SimpleSpanProcessor(options);
177
+ }
77
178
  //# sourceMappingURL=processor.batch.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @zudojs/observability — Log Processor
3
+ *
4
+ * Buffers log records and hands them to an exporter in batches.
5
+ *
6
+ * A logger writes synchronously and cannot await anything, so the naive
7
+ * bridge — `void exporter.export([record])` — exports one record per line and
8
+ * turns any exporter rejection into an unhandled rejection, which ends the
9
+ * process under Node's default. This transport is that bridge done properly:
10
+ * bounded queue, batched export, errors reported, and a `flush()` that
11
+ * shutdown can await so the last records are not lost.
12
+ */
13
+ import type { LogExporter, LogRecord, LogTransport } from "../types.js";
14
+ /** Options for {@link BatchLogProcessor}. */
15
+ export interface BatchLogProcessorOptions {
16
+ readonly exporter: LogExporter;
17
+ /** Records per export call. Default: 256. */
18
+ readonly batchSize?: number;
19
+ /** How often the queue is drained, in ms. Default: 1,000. */
20
+ readonly flushIntervalMs?: number;
21
+ /** Hard cap on queued records. Default: 4,096. */
22
+ readonly maxQueueSize?: number;
23
+ /** Reports export failures. */
24
+ readonly onError?: (error: unknown, source: string) => void;
25
+ /** Called when records are dropped because the queue was full. */
26
+ readonly onDrop?: (droppedCount: number) => void;
27
+ }
28
+ /** A {@link LogTransport} that batches records into a {@link LogExporter}. */
29
+ export declare class BatchLogProcessor implements LogTransport {
30
+ readonly name = "batch";
31
+ private readonly exporter;
32
+ private readonly batchSize;
33
+ private readonly flushIntervalMs;
34
+ private readonly maxQueueSize;
35
+ private readonly onError?;
36
+ private readonly onDrop?;
37
+ private buffer;
38
+ private timer?;
39
+ private shuttingDown;
40
+ private inFlight?;
41
+ private droppedRecords;
42
+ constructor(options: BatchLogProcessorOptions);
43
+ write(record: LogRecord): void;
44
+ private startTimer;
45
+ private stopTimer;
46
+ /** Records dropped because the queue was full. */
47
+ getDroppedCount(): number;
48
+ /** Records currently queued. */
49
+ getQueueSize(): number;
50
+ flush(): Promise<void>;
51
+ private drain;
52
+ shutdown(): Promise<void>;
53
+ }
54
+ /** Creates a batching log transport. */
55
+ export declare function createBatchLogProcessor(options: BatchLogProcessorOptions): BatchLogProcessor;
56
+ //# sourceMappingURL=processor.log.d.ts.map