autotel 7.0.1 → 7.2.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.
- package/README.md +25 -0
- package/dist/analysis.cjs +17 -0
- package/dist/analysis.d.cts +9 -1
- package/dist/analysis.d.ts +9 -1
- package/dist/analysis.js +17 -1
- package/dist/auto.cjs +2 -3
- package/dist/auto.js +1 -2
- package/dist/correlation-id.cjs +1 -1
- package/dist/correlation-id.js +1 -1
- package/dist/decorators.cjs +1 -1
- package/dist/decorators.d.cts +1 -1
- package/dist/decorators.d.ts +1 -1
- package/dist/decorators.js +1 -1
- package/dist/event.cjs +1 -1
- package/dist/event.js +1 -1
- package/dist/evidence.cjs +57 -0
- package/dist/evidence.d.cts +61 -0
- package/dist/evidence.d.ts +61 -0
- package/dist/evidence.js +50 -0
- package/dist/{functional-C2ONG9PK.cjs → functional-C925G95W.cjs} +68 -17
- package/dist/{functional-CJn2zY4o.d.cts → functional-CgWcRXGX.d.cts} +12 -5
- package/dist/{functional-nS5U4KCQ.js → functional-Doi0hFm3.js} +64 -13
- package/dist/{functional-DXy1cHFF.d.ts → functional-RFFpQzAV.d.ts} +12 -5
- package/dist/functional.cjs +1 -1
- package/dist/functional.d.cts +1 -1
- package/dist/functional.d.ts +1 -1
- package/dist/functional.js +1 -1
- package/dist/http.cjs +1 -1
- package/dist/http.js +1 -1
- package/dist/index.cjs +53 -15
- package/dist/index.d.cts +24 -4
- package/dist/index.d.ts +24 -4
- package/dist/index.js +40 -5
- package/dist/{init-DrlL1eis.d.cts → init-8GSy2bza.d.cts} +1 -1
- package/dist/{init-DDyaLzeg.js → init-DzAdgcFM.js} +2181 -1303
- package/dist/{init-BgnYi2sm.d.ts → init-UnKrXhpq.d.ts} +1 -1
- package/dist/{init-C5aHRqi2.cjs → init-aTZKsAwy.cjs} +2324 -1294
- package/dist/instrumentation.cjs +2 -3
- package/dist/instrumentation.js +1 -2
- package/dist/messaging.cjs +5 -5
- package/dist/messaging.js +2 -2
- package/dist/{sampling-CM5S2Lp3.d.ts → sampling-DBng4Foa.d.ts} +20 -1
- package/dist/{sampling-CmRdnXUi.d.cts → sampling-Er217Mge.d.cts} +20 -1
- package/dist/sampling.cjs +22 -601
- package/dist/sampling.d.cts +2 -2
- package/dist/sampling.d.ts +2 -2
- package/dist/sampling.js +2 -585
- package/dist/semantic-helpers.cjs +1 -1
- package/dist/semantic-helpers.js +1 -1
- package/dist/tail-sampling-processor.cjs +2 -26
- package/dist/tail-sampling-processor.js +1 -25
- package/dist/webhook.cjs +1 -1
- package/dist/webhook.js +1 -1
- package/dist/workflow-distributed.cjs +1 -1
- package/dist/workflow-distributed.js +1 -1
- package/dist/workflow.cjs +1 -1
- package/dist/workflow.js +1 -1
- package/dist/yaml-config.cjs +4 -248
- package/dist/yaml-config.d.cts +2 -2
- package/dist/yaml-config.d.ts +2 -2
- package/dist/yaml-config.js +1 -242
- package/package.json +6 -1
|
@@ -1,694 +1,1038 @@
|
|
|
1
|
-
import { _ as readProperty, f as describeValue, h as isFunction, i as asNumber, l as asString, r as asFunction, s as asRecord, t as asBoolean } from "./values-BenyvWKz.js";
|
|
1
|
+
import { _ as readProperty, d as asStringRecord, f as describeValue, h as isFunction, i as asNumber, l as asString, r as asFunction, s as asRecord, t as asBoolean } from "./values-BenyvWKz.js";
|
|
2
2
|
import { getForceFlushableProvider } from "./tracer-provider.js";
|
|
3
3
|
import { getConfig as getConfig$1 } from "./config.js";
|
|
4
|
-
import {
|
|
4
|
+
import { r as recordStructuredError } from "./structured-error-YIuka-FE.js";
|
|
5
5
|
import { n as safeRequire, t as requireModule } from "./node-require-DSPjaw_l.js";
|
|
6
|
-
import { TailSamplingSpanProcessor } from "./tail-sampling-processor.js";
|
|
7
6
|
import { FilteringSpanProcessor } from "./filtering-span-processor.js";
|
|
8
7
|
import { PolicyLogRecordProcessor, policySpanFilter, setPolicies, watchPolicyFile } from "./policy.js";
|
|
9
8
|
import { SpanNameNormalizingProcessor } from "./span-name-normalizer.js";
|
|
10
9
|
import { AttributeRedactingProcessor, REDACTOR_PRESETS, normalizeAttributeRedactorConfig } from "./attribute-redacting-processor.js";
|
|
11
10
|
import { t as PrettyConsoleExporter } from "./pretty-console-exporter-DqKl_q9z.js";
|
|
12
|
-
import { loadYamlConfig } from "./yaml-config.js";
|
|
13
11
|
import { t as CanonicalLogLineProcessor } from "./canonical-log-line-processor-hFUmS__K.js";
|
|
14
|
-
import { r as recordStructuredError } from "./structured-error-YIuka-FE.js";
|
|
15
12
|
import { resetMetrics } from "./metric.js";
|
|
16
13
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
|
17
14
|
import { AlwaysOffSampler, AlwaysOnSampler, BatchSpanProcessor, ConsoleSpanExporter, ParentBasedSampler, SamplingDecision, SimpleSpanProcessor, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
|
18
15
|
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
19
16
|
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
20
|
-
import { context, propagation, trace } from "@opentelemetry/api";
|
|
17
|
+
import { TraceFlags, context, propagation, trace } from "@opentelemetry/api";
|
|
18
|
+
import * as nodeAsyncHooks from "node:async_hooks";
|
|
21
19
|
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
22
20
|
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
|
|
21
|
+
import * as nodeFs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
23
|
import { constants } from "node:os";
|
|
24
|
-
import * as nodeAsyncHooks from "node:async_hooks";
|
|
25
24
|
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
26
25
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
27
26
|
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
|
28
27
|
|
|
29
|
-
//#region src/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const threshold = LOG_LEVELS[minLevel];
|
|
47
|
-
const wrap = (fn, level) => {
|
|
48
|
-
if (LOG_LEVELS[level] < threshold) return (() => {});
|
|
49
|
-
return ((...args) => fn(...args));
|
|
50
|
-
};
|
|
51
|
-
return {
|
|
52
|
-
debug: wrap(base.debug, "debug"),
|
|
53
|
-
info: wrap(base.info, "info"),
|
|
54
|
-
warn: wrap(base.warn, "warn"),
|
|
55
|
-
error: wrap(base.error, "error")
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
//#endregion
|
|
60
|
-
//#region src/posthog-logs.ts
|
|
61
|
-
var RedactingLogRecordProcessor = class {
|
|
62
|
-
wrapped;
|
|
63
|
-
redact;
|
|
64
|
-
constructor(wrapped, redact) {
|
|
65
|
-
this.wrapped = wrapped;
|
|
66
|
-
this.redact = redact;
|
|
28
|
+
//#region src/rate-limiter.ts
|
|
29
|
+
/**
|
|
30
|
+
* Token bucket rate limiter
|
|
31
|
+
*
|
|
32
|
+
* Allows bursts up to burstCapacity, then smooths to maxEventsPerSecond.
|
|
33
|
+
* Thread-safe for async operations.
|
|
34
|
+
*/
|
|
35
|
+
var TokenBucketRateLimiter = class {
|
|
36
|
+
tokens;
|
|
37
|
+
maxTokens;
|
|
38
|
+
refillRate;
|
|
39
|
+
lastRefill;
|
|
40
|
+
constructor(config) {
|
|
41
|
+
this.maxTokens = config.burstCapacity || config.maxEventsPerSecond * 2;
|
|
42
|
+
this.tokens = this.maxTokens;
|
|
43
|
+
this.refillRate = config.maxEventsPerSecond / 1e3;
|
|
44
|
+
this.lastRefill = Date.now();
|
|
67
45
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
});
|
|
46
|
+
/**
|
|
47
|
+
* Try to consume a token (allow an event)
|
|
48
|
+
* Returns true if allowed, false if rate limit exceeded
|
|
49
|
+
*/
|
|
50
|
+
tryConsume(count = 1) {
|
|
51
|
+
this.refill();
|
|
52
|
+
if (this.tokens >= count) {
|
|
53
|
+
this.tokens -= count;
|
|
54
|
+
return true;
|
|
78
55
|
}
|
|
79
|
-
|
|
56
|
+
return false;
|
|
80
57
|
}
|
|
81
|
-
|
|
82
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Wait until a token is available (async rate limiting)
|
|
60
|
+
* Returns a promise that resolves when the event can be processed
|
|
61
|
+
*/
|
|
62
|
+
async waitForToken(count = 1) {
|
|
63
|
+
this.refill();
|
|
64
|
+
if (this.tokens >= count) {
|
|
65
|
+
this.tokens -= count;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const tokensNeeded = count - this.tokens;
|
|
69
|
+
const waitMs = Math.ceil(tokensNeeded / this.refillRate);
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
71
|
+
return this.waitForToken(count);
|
|
83
72
|
}
|
|
84
|
-
|
|
85
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Refill tokens based on elapsed time
|
|
75
|
+
*/
|
|
76
|
+
refill() {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const tokensToAdd = (now - this.lastRefill) * this.refillRate;
|
|
79
|
+
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
|
|
80
|
+
this.lastRefill = now;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get current available tokens (for testing/debugging)
|
|
84
|
+
*/
|
|
85
|
+
getAvailableTokens() {
|
|
86
|
+
this.refill();
|
|
87
|
+
return Math.floor(this.tokens);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Reset the rate limiter (for testing)
|
|
91
|
+
*/
|
|
92
|
+
reset() {
|
|
93
|
+
this.tokens = this.maxTokens;
|
|
94
|
+
this.lastRefill = Date.now();
|
|
86
95
|
}
|
|
87
96
|
};
|
|
88
|
-
/**
|
|
89
|
-
* Build log record processors for PostHog OTLP logs integration.
|
|
90
|
-
*
|
|
91
|
-
* Resolution order:
|
|
92
|
-
* 1. config.url if provided
|
|
93
|
-
* 2. POSTHOG_LOGS_URL env var
|
|
94
|
-
* 3. Empty array (disabled)
|
|
95
|
-
*/
|
|
96
|
-
function buildPostHogLogProcessors(config, stringRedactor) {
|
|
97
|
-
const url = config?.url || process.env.POSTHOG_LOGS_URL;
|
|
98
|
-
if (!url) return [];
|
|
99
|
-
const sdkLogs = safeRequire("@opentelemetry/sdk-logs");
|
|
100
|
-
const exporterModule = safeRequire("@opentelemetry/exporter-logs-otlp-http");
|
|
101
|
-
if (!sdkLogs || !exporterModule) return [];
|
|
102
|
-
const exporter = new exporterModule.OTLPLogExporter({ url });
|
|
103
|
-
let processor = new sdkLogs.BatchLogRecordProcessor({ exporter });
|
|
104
|
-
if (stringRedactor) processor = new RedactingLogRecordProcessor(processor, stringRedactor);
|
|
105
|
-
return [processor];
|
|
106
|
-
}
|
|
107
97
|
|
|
108
98
|
//#endregion
|
|
109
|
-
//#region src/
|
|
99
|
+
//#region src/correlation-id.ts
|
|
110
100
|
/**
|
|
111
|
-
*
|
|
101
|
+
* Correlation ID utilities for event-driven observability
|
|
112
102
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
103
|
+
* Provides a stable join key across events, logs, and spans even when traces fragment.
|
|
104
|
+
* Format: 16 hex chars (64 bits), crypto-random, URL-safe.
|
|
115
105
|
*
|
|
116
|
-
*
|
|
106
|
+
* Lifecycle:
|
|
107
|
+
* 1. Generated at boundary root (HTTP server span, message process span, cron job span)
|
|
108
|
+
* 2. Reused within context (nested work shares it via AsyncLocalStorage)
|
|
109
|
+
* 3. Propagated via baggage (optional, default OFF to avoid header bloat)
|
|
110
|
+
*
|
|
111
|
+
* @example Basic usage
|
|
117
112
|
* ```typescript
|
|
118
|
-
*
|
|
119
|
-
* service: 'my-app',
|
|
120
|
-
* baggage: true // Uses default 'baggage.' prefix
|
|
121
|
-
* });
|
|
113
|
+
* import { generateCorrelationId, getCorrelationId } from 'autotel/correlation-id';
|
|
122
114
|
*
|
|
123
|
-
* //
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* fn: async () => {
|
|
127
|
-
* // Span has baggage.tenant.id and baggage.user.id attributes!
|
|
128
|
-
* }
|
|
129
|
-
* });
|
|
130
|
-
* ```
|
|
115
|
+
* // Generate a new correlation ID
|
|
116
|
+
* const id = generateCorrelationId();
|
|
117
|
+
* // Returns: 'a1b2c3d4e5f67890'
|
|
131
118
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* init({
|
|
135
|
-
* service: 'my-app',
|
|
136
|
-
* baggage: 'ctx' // Uses 'ctx.' prefix
|
|
137
|
-
* });
|
|
138
|
-
* // Creates attributes: ctx.tenant.id, ctx.user.id
|
|
119
|
+
* // Get current correlation ID from context
|
|
120
|
+
* const currentId = getCorrelationId();
|
|
139
121
|
* ```
|
|
140
122
|
*/
|
|
141
|
-
var BaggageSpanProcessor = class {
|
|
142
|
-
prefix;
|
|
143
|
-
constructor(options = {}) {
|
|
144
|
-
this.prefix = options.prefix ?? "baggage.";
|
|
145
|
-
}
|
|
146
|
-
onStart(span, parentContext) {
|
|
147
|
-
let baggage = propagation.getBaggage(parentContext);
|
|
148
|
-
if (!baggage) baggage = propagation.getBaggage(context.active());
|
|
149
|
-
if (!baggage) try {
|
|
150
|
-
const { getActiveContextWithBaggage } = requireModule("./trace-context");
|
|
151
|
-
const storedContext = getActiveContextWithBaggage();
|
|
152
|
-
baggage = propagation.getBaggage(storedContext);
|
|
153
|
-
} catch {}
|
|
154
|
-
if (!baggage) return;
|
|
155
|
-
for (const [key, entry] of baggage.getAllEntries()) span.setAttribute(`${this.prefix}${key}`, entry.value);
|
|
156
|
-
}
|
|
157
|
-
onEnd(_span) {}
|
|
158
|
-
async shutdown() {}
|
|
159
|
-
async forceFlush() {}
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
//#endregion
|
|
163
|
-
//#region src/redact-values.ts
|
|
164
|
-
/** Standalone string redaction for use outside the span processor pipeline. */
|
|
165
|
-
function createStringRedactor(config) {
|
|
166
|
-
const resolved = typeof config === "string" ? REDACTOR_PRESETS[config] : config;
|
|
167
|
-
const valuePatterns = resolved.valuePatterns ?? [];
|
|
168
|
-
const defaultReplacement = resolved.replacement ?? "[REDACTED]";
|
|
169
|
-
return (value) => {
|
|
170
|
-
let result = value;
|
|
171
|
-
for (const { pattern, replacement, mask } of valuePatterns) {
|
|
172
|
-
pattern.lastIndex = 0;
|
|
173
|
-
result = mask ? result.replaceAll(pattern, (match) => mask(match)) : result.replaceAll(pattern, replacement ?? defaultReplacement);
|
|
174
|
-
}
|
|
175
|
-
return result;
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
//#endregion
|
|
180
|
-
//#region src/env-config.ts
|
|
181
123
|
/**
|
|
182
|
-
*
|
|
124
|
+
* AsyncLocalStorage for storing correlation ID
|
|
125
|
+
* This allows correlation IDs to persist across async boundaries
|
|
183
126
|
*/
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
const url = new URL(urlString);
|
|
187
|
-
return url.protocol === "http:" || url.protocol === "https:";
|
|
188
|
-
} catch {
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
127
|
+
const correlationStorage = new nodeAsyncHooks.AsyncLocalStorage();
|
|
192
128
|
/**
|
|
193
|
-
*
|
|
129
|
+
* Baggage key for correlation ID propagation
|
|
194
130
|
*/
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (process.env.OTEL_EXPORTER_OTLP_PROTOCOL) {
|
|
214
|
-
const value = process.env.OTEL_EXPORTER_OTLP_PROTOCOL.trim().toLowerCase();
|
|
215
|
-
if (value === "http" || value === "http/json") env.OTEL_EXPORTER_OTLP_PROTOCOL = "http";
|
|
216
|
-
else if (value === "http/protobuf" || value === "grpc") env.OTEL_EXPORTER_OTLP_PROTOCOL = value;
|
|
217
|
-
}
|
|
218
|
-
if (process.env.OTEL_TRACES_SAMPLER) {
|
|
219
|
-
const value = process.env.OTEL_TRACES_SAMPLER.trim();
|
|
220
|
-
if (value) env.OTEL_TRACES_SAMPLER = value;
|
|
221
|
-
}
|
|
222
|
-
if (process.env.OTEL_TRACES_SAMPLER_ARG) {
|
|
223
|
-
const value = process.env.OTEL_TRACES_SAMPLER_ARG.trim();
|
|
224
|
-
if (value) env.OTEL_TRACES_SAMPLER_ARG = value;
|
|
225
|
-
}
|
|
226
|
-
return env;
|
|
227
|
-
}
|
|
228
|
-
function parseRatioSamplerArg(samplerName, samplerArg) {
|
|
229
|
-
if (samplerArg === void 0) return 1;
|
|
230
|
-
const ratio = Number(samplerArg);
|
|
231
|
-
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
|
|
232
|
-
console.error(`[autotel] Invalid OTEL_TRACES_SAMPLER_ARG="${samplerArg}" for ${samplerName}. Expected a number in [0..1]. Falling back to 1.0.`);
|
|
233
|
-
return 1;
|
|
234
|
-
}
|
|
235
|
-
return ratio;
|
|
236
|
-
}
|
|
237
|
-
function warnOnUnusedSamplerArg(samplerName, samplerArg) {
|
|
238
|
-
if (samplerArg !== void 0) console.error(`[autotel] OTEL_TRACES_SAMPLER_ARG is not used by OTEL_TRACES_SAMPLER="${samplerName}". Ignoring value "${samplerArg}".`);
|
|
131
|
+
const CORRELATION_ID_BAGGAGE_KEY = "autotel.correlation_id";
|
|
132
|
+
/**
|
|
133
|
+
* Generate a new correlation ID
|
|
134
|
+
*
|
|
135
|
+
* Format: 16 hex chars (64 bits), crypto-random, URL-safe
|
|
136
|
+
*
|
|
137
|
+
* @returns A new correlation ID
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```typescript
|
|
141
|
+
* const id = generateCorrelationId();
|
|
142
|
+
* // Returns: 'a1b2c3d4e5f67890'
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
function generateCorrelationId() {
|
|
146
|
+
const bytes = /* @__PURE__ */ new Uint8Array(8);
|
|
147
|
+
crypto.getRandomValues(bytes);
|
|
148
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
239
149
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Get the current correlation ID from context
|
|
152
|
+
*
|
|
153
|
+
* Resolution order:
|
|
154
|
+
* 1. AsyncLocalStorage (from explicit setCorrelationId or runWithCorrelationId)
|
|
155
|
+
* 2. Baggage (if propagated from upstream)
|
|
156
|
+
* 3. Active span's trace ID (first 16 chars as fallback)
|
|
157
|
+
* 4. undefined (if not in any context)
|
|
158
|
+
*
|
|
159
|
+
* @returns Current correlation ID or undefined
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```typescript
|
|
163
|
+
* const id = getCorrelationId();
|
|
164
|
+
* if (id) {
|
|
165
|
+
* console.log('Correlation ID:', id);
|
|
166
|
+
* }
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
function getCorrelationId() {
|
|
170
|
+
const storedId = correlationStorage.getStore()?.value;
|
|
171
|
+
if (storedId) return storedId;
|
|
172
|
+
const activeContext = context.active();
|
|
173
|
+
const baggageEntry = propagation.getBaggage(activeContext)?.getEntry(CORRELATION_ID_BAGGAGE_KEY);
|
|
174
|
+
if (baggageEntry?.value) return baggageEntry.value;
|
|
175
|
+
const span = trace.getActiveSpan();
|
|
176
|
+
if (span) return span.spanContext().traceId.slice(0, 16);
|
|
267
177
|
}
|
|
268
178
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
179
|
+
* Get or create a correlation ID
|
|
180
|
+
*
|
|
181
|
+
* If a correlation ID exists in the current context, returns it.
|
|
182
|
+
* Otherwise, generates a new one.
|
|
183
|
+
*
|
|
184
|
+
* @returns Existing or new correlation ID
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* ```typescript
|
|
188
|
+
* const id = getOrCreateCorrelationId();
|
|
189
|
+
* // Always returns a valid correlation ID
|
|
190
|
+
* ```
|
|
271
191
|
*/
|
|
272
|
-
function
|
|
273
|
-
|
|
274
|
-
const attributes = {};
|
|
275
|
-
const pairs = input.split(",");
|
|
276
|
-
for (const pair of pairs) {
|
|
277
|
-
const trimmedPair = pair.trim();
|
|
278
|
-
if (!trimmedPair) continue;
|
|
279
|
-
const equalIndex = trimmedPair.indexOf("=");
|
|
280
|
-
if (equalIndex === -1) continue;
|
|
281
|
-
const key = trimmedPair.slice(0, equalIndex).trim();
|
|
282
|
-
const value = trimmedPair.slice(equalIndex + 1).trim();
|
|
283
|
-
if (key && value) attributes[key] = value;
|
|
284
|
-
}
|
|
285
|
-
return attributes;
|
|
192
|
+
function getOrCreateCorrelationId() {
|
|
193
|
+
return getCorrelationId() ?? generateCorrelationId();
|
|
286
194
|
}
|
|
287
195
|
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
196
|
+
* Run a function with a specific correlation ID in context
|
|
197
|
+
*
|
|
198
|
+
* The correlation ID will be available via getCorrelationId() throughout
|
|
199
|
+
* the execution of the function and any async operations it spawns.
|
|
200
|
+
*
|
|
201
|
+
* @param correlationId - Correlation ID to use
|
|
202
|
+
* @param fn - Function to execute
|
|
203
|
+
* @returns The return value of the function
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```typescript
|
|
207
|
+
* await runWithCorrelationId('abc123', async () => {
|
|
208
|
+
* // getCorrelationId() returns 'abc123' here
|
|
209
|
+
* await processRequest();
|
|
210
|
+
* });
|
|
211
|
+
* ```
|
|
290
212
|
*/
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
const headers = {};
|
|
294
|
-
const pairs = input.split(",");
|
|
295
|
-
for (const pair of pairs) {
|
|
296
|
-
const trimmedPair = pair.trim();
|
|
297
|
-
if (!trimmedPair) continue;
|
|
298
|
-
const equalIndex = trimmedPair.indexOf("=");
|
|
299
|
-
if (equalIndex === -1) continue;
|
|
300
|
-
const key = trimmedPair.slice(0, equalIndex).trim();
|
|
301
|
-
const value = trimmedPair.slice(equalIndex + 1).trim();
|
|
302
|
-
if (key && value) headers[key] = value;
|
|
303
|
-
}
|
|
304
|
-
return headers;
|
|
213
|
+
function runWithCorrelationId(correlationId, fn) {
|
|
214
|
+
return correlationStorage.run({ value: correlationId }, fn);
|
|
305
215
|
}
|
|
306
216
|
/**
|
|
307
|
-
*
|
|
217
|
+
* Set correlation ID in the current context (mutates context)
|
|
218
|
+
*
|
|
219
|
+
* Note: This updates the AsyncLocalStorage context. For proper scoping
|
|
220
|
+
* across async boundaries, prefer runWithCorrelationId() instead.
|
|
221
|
+
*
|
|
222
|
+
* @param correlationId - Correlation ID to set
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```typescript
|
|
226
|
+
* setCorrelationId('abc123');
|
|
227
|
+
* // Now getCorrelationId() returns 'abc123'
|
|
228
|
+
* ```
|
|
308
229
|
*/
|
|
309
|
-
function
|
|
310
|
-
|
|
311
|
-
if (env.OTEL_SERVICE_NAME) config.service = env.OTEL_SERVICE_NAME;
|
|
312
|
-
if (env.OTEL_EXPORTER_OTLP_ENDPOINT) config.endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
313
|
-
if (env.OTEL_EXPORTER_OTLP_PROTOCOL) config.protocol = env.OTEL_EXPORTER_OTLP_PROTOCOL;
|
|
314
|
-
if (env.OTEL_EXPORTER_OTLP_HEADERS) config.headers = parseOtlpHeaders(env.OTEL_EXPORTER_OTLP_HEADERS);
|
|
315
|
-
const resourceAttrs = parseResourceAttributes(env.OTEL_RESOURCE_ATTRIBUTES);
|
|
316
|
-
if (Object.keys(resourceAttrs).length > 0) config.resourceAttributes = resourceAttrs;
|
|
317
|
-
const sampler = createSamplerFromEnv(env);
|
|
318
|
-
if (sampler) config.otelSampler = sampler;
|
|
319
|
-
return config;
|
|
230
|
+
function setCorrelationId(correlationId) {
|
|
231
|
+
enterOrRun(correlationStorage, correlationId);
|
|
320
232
|
}
|
|
321
233
|
/**
|
|
322
|
-
*
|
|
234
|
+
* Set correlation ID in baggage for propagation
|
|
235
|
+
*
|
|
236
|
+
* This adds the correlation ID to the W3C baggage header, allowing it
|
|
237
|
+
* to be propagated to downstream services.
|
|
238
|
+
*
|
|
239
|
+
* Note: Only use this when you explicitly want cross-service propagation.
|
|
240
|
+
* Default is OFF to avoid header bloat.
|
|
241
|
+
*
|
|
242
|
+
* @param correlationId - Correlation ID to propagate
|
|
243
|
+
* @returns New context with baggage set
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* ```typescript
|
|
247
|
+
* const newContext = setCorrelationIdInBaggage('abc123');
|
|
248
|
+
* context.with(newContext, () => {
|
|
249
|
+
* // Baggage will be propagated in outgoing requests
|
|
250
|
+
* });
|
|
251
|
+
* ```
|
|
323
252
|
*/
|
|
324
|
-
function
|
|
325
|
-
|
|
253
|
+
function setCorrelationIdInBaggage(correlationId) {
|
|
254
|
+
const activeContext = context.active();
|
|
255
|
+
let baggage = propagation.getBaggage(activeContext) ?? propagation.createBaggage();
|
|
256
|
+
baggage = baggage.setEntry(CORRELATION_ID_BAGGAGE_KEY, { value: correlationId });
|
|
257
|
+
return propagation.setBaggage(activeContext, baggage);
|
|
326
258
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
function resolveDevtoolsConfig(config) {
|
|
333
|
-
if (!config) return {
|
|
334
|
-
enabled: false,
|
|
335
|
-
endpoint: void 0,
|
|
336
|
-
embedded: false,
|
|
337
|
-
host: defaultHost,
|
|
338
|
-
port: defaultPort,
|
|
339
|
-
verbose: false
|
|
340
|
-
};
|
|
341
|
-
if (config === true) return {
|
|
342
|
-
enabled: true,
|
|
343
|
-
endpoint: `http://${defaultHost}:${defaultPort}`,
|
|
344
|
-
embedded: false,
|
|
345
|
-
host: defaultHost,
|
|
346
|
-
port: defaultPort,
|
|
347
|
-
verbose: false
|
|
348
|
-
};
|
|
349
|
-
const enabled = config.enabled ?? true;
|
|
350
|
-
const host = config.host ?? defaultHost;
|
|
351
|
-
const port = config.port ?? defaultPort;
|
|
352
|
-
const endpoint = config.endpoint ?? `http://${host}:${port}`;
|
|
353
|
-
return {
|
|
354
|
-
enabled,
|
|
355
|
-
endpoint: enabled ? endpoint : void 0,
|
|
356
|
-
embedded: enabled && (config.embedded ?? false),
|
|
357
|
-
host,
|
|
358
|
-
port,
|
|
359
|
-
verbose: config.verbose ?? false
|
|
360
|
-
};
|
|
259
|
+
/**
|
|
260
|
+
* Get the correlation storage instance (for internal use in init/shutdown)
|
|
261
|
+
*/
|
|
262
|
+
function getCorrelationStorage() {
|
|
263
|
+
return correlationStorage;
|
|
361
264
|
}
|
|
362
265
|
|
|
363
266
|
//#endregion
|
|
364
|
-
//#region src/
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
267
|
+
//#region src/event-queue.ts
|
|
268
|
+
const DEFAULT_CONFIG$2 = {
|
|
269
|
+
maxSize: 5e4,
|
|
270
|
+
batchSize: 100,
|
|
271
|
+
flushInterval: 1e4,
|
|
272
|
+
maxRetries: 3,
|
|
273
|
+
rateLimit: {
|
|
274
|
+
maxEventsPerSecond: 100,
|
|
275
|
+
burstCapacity: 200
|
|
276
|
+
}
|
|
277
|
+
};
|
|
372
278
|
/**
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
279
|
+
* Get subscriber name for metrics (stable, low-cardinality)
|
|
280
|
+
*
|
|
281
|
+
* Priority:
|
|
282
|
+
* 1. Explicit config: subscriber.name
|
|
283
|
+
* 2. Class static property (if available)
|
|
284
|
+
* 3. Fallback: lowercase class name without "Subscriber" suffix
|
|
377
285
|
*/
|
|
378
|
-
|
|
286
|
+
function getSubscriberName(subscriber) {
|
|
287
|
+
if (subscriber.name) return subscriber.name.toLowerCase();
|
|
288
|
+
return (subscriber.constructor?.name || "unknown").replace(/Subscriber$/i, "").toLowerCase();
|
|
289
|
+
}
|
|
379
290
|
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
* waits on rather than tearing down queues mid-drain.
|
|
291
|
+
* Subscribers whose `shutdown()` has run. Terminal for the client they wrap, so
|
|
292
|
+
* a queue rebuilt from the same config must not reuse them.
|
|
383
293
|
*/
|
|
384
|
-
|
|
385
|
-
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2e3;
|
|
386
|
-
function runShutdownOnce(shutdown, timeoutMs) {
|
|
387
|
-
if (shutdownInFlight) return shutdownInFlight;
|
|
388
|
-
let timeoutHandle;
|
|
389
|
-
const shutdownAttempt = Promise.resolve(exitFlushInFlight).then(shutdown).catch(() => void 0);
|
|
390
|
-
const timeout = new Promise((resolve) => {
|
|
391
|
-
timeoutHandle = setTimeout(resolve, timeoutMs);
|
|
392
|
-
timeoutHandle.unref();
|
|
393
|
-
});
|
|
394
|
-
shutdownInFlight = Promise.race([shutdownAttempt, timeout]).then(() => {
|
|
395
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
396
|
-
});
|
|
397
|
-
return shutdownInFlight;
|
|
398
|
-
}
|
|
399
|
-
const DEFAULT_SIGNALS = ["SIGTERM", "SIGINT"];
|
|
400
|
-
function signalExitCode(signal) {
|
|
401
|
-
return 128 + constants.signals[signal];
|
|
402
|
-
}
|
|
294
|
+
const shutDownSubscribers = /* @__PURE__ */ new WeakSet();
|
|
403
295
|
/**
|
|
404
|
-
*
|
|
296
|
+
* Events queue with batching and backpressure
|
|
405
297
|
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
298
|
+
* Features:
|
|
299
|
+
* - Batches events for efficient sending
|
|
300
|
+
* - Bounded queue with drop-oldest policy (prod) or blocking (dev)
|
|
301
|
+
* - Exponential backoff retry
|
|
302
|
+
* - Rate limiting to prevent overwhelming subscribers
|
|
303
|
+
* - Graceful flush on shutdown
|
|
410
304
|
*/
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
exitScheduled = true;
|
|
430
|
-
runShutdownOnce(shutdown, timeoutMs).then(() => {
|
|
431
|
-
process.exit(resolvedExitCode);
|
|
432
|
-
});
|
|
433
|
-
};
|
|
434
|
-
for (const signal of config.signals ?? DEFAULT_SIGNALS) {
|
|
435
|
-
const listener = () => {
|
|
436
|
-
shutdownAndExit(signalExitCode(signal), false);
|
|
305
|
+
var EventQueue = class {
|
|
306
|
+
queue = [];
|
|
307
|
+
flushTimer = null;
|
|
308
|
+
config;
|
|
309
|
+
subscribers;
|
|
310
|
+
rateLimiter;
|
|
311
|
+
flushPromise = null;
|
|
312
|
+
isShuttingDown = false;
|
|
313
|
+
metrics = null;
|
|
314
|
+
observableCleanups = [];
|
|
315
|
+
subscriberHealthy = /* @__PURE__ */ new Map();
|
|
316
|
+
constructor(subscribers, config) {
|
|
317
|
+
const live = subscribers.filter((s) => !shutDownSubscribers.has(s));
|
|
318
|
+
if (live.length < subscribers.length) getLogger().warn({ subscribers: subscribers.filter((s) => shutDownSubscribers.has(s)).map((s) => getSubscriberName(s)) }, "[autotel] Subscriber already shut down, events will not be delivered; pass new subscriber instances to init()");
|
|
319
|
+
this.subscribers = live;
|
|
320
|
+
this.config = {
|
|
321
|
+
...DEFAULT_CONFIG$2,
|
|
322
|
+
...config
|
|
437
323
|
};
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
324
|
+
this.rateLimiter = this.config.rateLimit ? new TokenBucketRateLimiter(this.config.rateLimit) : null;
|
|
325
|
+
for (const subscriber of this.subscribers) {
|
|
326
|
+
const name = getSubscriberName(subscriber);
|
|
327
|
+
this.subscriberHealthy.set(name, true);
|
|
328
|
+
}
|
|
329
|
+
this.initMetrics();
|
|
442
330
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Initialize OTel metrics for queue observability
|
|
333
|
+
*/
|
|
334
|
+
initMetrics() {
|
|
335
|
+
const meter = getConfig$1().meter;
|
|
336
|
+
const queueSize = meter.createObservableGauge("autotel.event_delivery.queue.size", {
|
|
337
|
+
description: "Current number of events in the delivery queue",
|
|
338
|
+
unit: "count"
|
|
339
|
+
});
|
|
340
|
+
const queueSizeCallback = (observableResult) => {
|
|
341
|
+
observableResult.observe(this.queue.length);
|
|
447
342
|
};
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
343
|
+
queueSize.addCallback(queueSizeCallback);
|
|
344
|
+
this.observableCleanups.push(() => queueSize.removeCallback(queueSizeCallback));
|
|
345
|
+
const oldestAge = meter.createObservableGauge("autotel.event_delivery.queue.oldest_age_ms", {
|
|
346
|
+
description: "Age of the oldest event in the queue in milliseconds",
|
|
347
|
+
unit: "ms"
|
|
348
|
+
});
|
|
349
|
+
const oldestAgeCallback = (observableResult) => {
|
|
350
|
+
if (this.queue.length > 0) {
|
|
351
|
+
const oldest = this.queue[0];
|
|
352
|
+
const ageMs = Date.now() - oldest.timestamp;
|
|
353
|
+
observableResult.observe(ageMs);
|
|
354
|
+
} else observableResult.observe(0);
|
|
451
355
|
};
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
process.removeListener("unhandledRejection", unhandledRejectionListener);
|
|
356
|
+
oldestAge.addCallback(oldestAgeCallback);
|
|
357
|
+
this.observableCleanups.push(() => oldestAge.removeCallback(oldestAgeCallback));
|
|
358
|
+
const delivered = meter.createCounter("autotel.event_delivery.queue.delivered", {
|
|
359
|
+
description: "Number of events successfully delivered to subscribers",
|
|
360
|
+
unit: "count"
|
|
458
361
|
});
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
* Flush telemetry when the process runs to completion.
|
|
463
|
-
*
|
|
464
|
-
* The signal and fatal-error handlers above cover a process that is stopped or
|
|
465
|
-
* that crashes. Neither fires when a script simply finishes: the event loop
|
|
466
|
-
* drains and Node exits, taking whatever the batch span processor was still
|
|
467
|
-
* holding with it. `beforeExit` is the only hook for that case, and it is the
|
|
468
|
-
* one that matters for CLIs, cron jobs, CI steps and serverless handlers.
|
|
469
|
-
*
|
|
470
|
-
* A flush, never a shutdown: `beforeExit` fires on *any* event-loop drain, not
|
|
471
|
-
* only the final one, so tearing the SDK down here would silently kill
|
|
472
|
-
* telemetry in a process that goes on to do more work.
|
|
473
|
-
*/
|
|
474
|
-
function installExitFlush(flushTelemetry, timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
|
|
475
|
-
removeExitFlush?.();
|
|
476
|
-
exitFlushInFlight = void 0;
|
|
477
|
-
const listener = (code) => {
|
|
478
|
-
if (exitFlushInFlight || shutdownInFlight) return;
|
|
479
|
-
const deadline = setTimeout(() => {
|
|
480
|
-
process.exit(process.exitCode ?? code);
|
|
481
|
-
}, timeoutMs);
|
|
482
|
-
exitFlushInFlight = flushTelemetry().catch(() => void 0).finally(() => {
|
|
483
|
-
clearTimeout(deadline);
|
|
362
|
+
const failed = meter.createCounter("autotel.event_delivery.queue.failed", {
|
|
363
|
+
description: "Number of events that failed delivery after all retry attempts",
|
|
364
|
+
unit: "count"
|
|
484
365
|
});
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
refillRate;
|
|
512
|
-
lastRefill;
|
|
513
|
-
constructor(config) {
|
|
514
|
-
this.maxTokens = config.burstCapacity || config.maxEventsPerSecond * 2;
|
|
515
|
-
this.tokens = this.maxTokens;
|
|
516
|
-
this.refillRate = config.maxEventsPerSecond / 1e3;
|
|
517
|
-
this.lastRefill = Date.now();
|
|
366
|
+
const dropped = meter.createCounter("autotel.event_delivery.queue.dropped", {
|
|
367
|
+
description: "Number of events dropped from the queue",
|
|
368
|
+
unit: "count"
|
|
369
|
+
});
|
|
370
|
+
const latency = meter.createHistogram("autotel.event_delivery.queue.latency_ms", {
|
|
371
|
+
description: "Event delivery latency from enqueue to successful send",
|
|
372
|
+
unit: "ms"
|
|
373
|
+
});
|
|
374
|
+
const subscriberHealth = meter.createObservableGauge("autotel.event_delivery.subscriber.health", {
|
|
375
|
+
description: "Subscriber health status (1=healthy, 0=unhealthy)",
|
|
376
|
+
unit: "1"
|
|
377
|
+
});
|
|
378
|
+
const subscriberHealthCallback = (observableResult) => {
|
|
379
|
+
for (const [subscriberName, isHealthy] of this.subscriberHealthy) observableResult.observe(isHealthy ? 1 : 0, { subscriber: subscriberName });
|
|
380
|
+
};
|
|
381
|
+
subscriberHealth.addCallback(subscriberHealthCallback);
|
|
382
|
+
this.observableCleanups.push(() => subscriberHealth.removeCallback(subscriberHealthCallback));
|
|
383
|
+
this.metrics = {
|
|
384
|
+
queueSize,
|
|
385
|
+
oldestAge,
|
|
386
|
+
delivered,
|
|
387
|
+
failed,
|
|
388
|
+
dropped,
|
|
389
|
+
latency,
|
|
390
|
+
subscriberHealth
|
|
391
|
+
};
|
|
518
392
|
}
|
|
519
393
|
/**
|
|
520
|
-
*
|
|
521
|
-
* Returns true if allowed, false if rate limit exceeded
|
|
394
|
+
* Record a dropped event with reason and emit debug breadcrumb
|
|
522
395
|
*/
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
if (
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
396
|
+
recordDropped(reason, event, subscriberName) {
|
|
397
|
+
const attrs = { reason };
|
|
398
|
+
if (subscriberName) attrs.subscriber = subscriberName;
|
|
399
|
+
this.metrics?.dropped.add(1, attrs);
|
|
400
|
+
const logLevel = reason === "payload_invalid" ? "error" : "warn";
|
|
401
|
+
const logger = getLogger();
|
|
402
|
+
if (logLevel === "error") logger.error({
|
|
403
|
+
eventName: event?.name,
|
|
404
|
+
subscriber: subscriberName,
|
|
405
|
+
reason,
|
|
406
|
+
correlationId: event?._correlationId,
|
|
407
|
+
traceId: event?._traceId
|
|
408
|
+
}, `[autotel] Event dropped: ${reason}`);
|
|
409
|
+
else logger.warn({
|
|
410
|
+
eventName: event?.name,
|
|
411
|
+
subscriber: subscriberName,
|
|
412
|
+
reason,
|
|
413
|
+
correlationId: event?._correlationId,
|
|
414
|
+
traceId: event?._traceId
|
|
415
|
+
}, `[autotel] Event dropped: ${reason}`);
|
|
416
|
+
}
|
|
531
417
|
/**
|
|
532
|
-
*
|
|
533
|
-
*
|
|
418
|
+
* Record permanent delivery failure (after all retries exhausted)
|
|
419
|
+
* Increments failed counter and logs error
|
|
534
420
|
*/
|
|
535
|
-
|
|
536
|
-
this.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
421
|
+
recordFailed(event, subscriberName, error) {
|
|
422
|
+
this.metrics?.failed.add(1, { subscriber: subscriberName });
|
|
423
|
+
this.subscriberHealthy.set(subscriberName, false);
|
|
424
|
+
getLogger().error({
|
|
425
|
+
eventName: event.name,
|
|
426
|
+
subscriber: subscriberName,
|
|
427
|
+
correlationId: event._correlationId,
|
|
428
|
+
traceId: event._traceId,
|
|
429
|
+
err: error
|
|
430
|
+
}, `[autotel] Event delivery failed after all retries`);
|
|
545
431
|
}
|
|
546
432
|
/**
|
|
547
|
-
*
|
|
433
|
+
* Mark subscriber as unhealthy on transient failure (without incrementing failed counter)
|
|
434
|
+
* Used during retry attempts - only recordFailed should increment the counter
|
|
548
435
|
*/
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const tokensToAdd = (now - this.lastRefill) * this.refillRate;
|
|
552
|
-
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
|
|
553
|
-
this.lastRefill = now;
|
|
436
|
+
markSubscriberUnhealthy(subscriberName) {
|
|
437
|
+
this.subscriberHealthy.set(subscriberName, false);
|
|
554
438
|
}
|
|
555
439
|
/**
|
|
556
|
-
*
|
|
440
|
+
* Record successful delivery
|
|
557
441
|
*/
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
442
|
+
recordDelivered(event, subscriberName, startTime) {
|
|
443
|
+
const latencyMs = Date.now() - startTime;
|
|
444
|
+
this.metrics?.delivered.add(1, { subscriber: subscriberName });
|
|
445
|
+
this.metrics?.latency.record(latencyMs, { subscriber: subscriberName });
|
|
446
|
+
this.subscriberHealthy.set(subscriberName, true);
|
|
561
447
|
}
|
|
562
448
|
/**
|
|
563
|
-
*
|
|
449
|
+
* Enqueue an event for sending
|
|
450
|
+
*
|
|
451
|
+
* Backpressure policy:
|
|
452
|
+
* - Drops oldest event and logs warning if queue is full (same behavior in all environments)
|
|
564
453
|
*/
|
|
565
|
-
|
|
566
|
-
this.
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
*/
|
|
582
|
-
const contextStorage = new nodeAsyncHooks.AsyncLocalStorage();
|
|
583
|
-
/**
|
|
584
|
-
* Get the context storage instance (for initialization in functional.ts)
|
|
585
|
-
*/
|
|
586
|
-
function getContextStorage() {
|
|
587
|
-
return contextStorage;
|
|
588
|
-
}
|
|
589
|
-
/**
|
|
590
|
-
* Get the active OTel context with the latest stored baggage overlaid.
|
|
591
|
-
* Span identity always comes from the active OTel scope.
|
|
592
|
-
*/
|
|
593
|
-
function getActiveContextWithBaggage() {
|
|
594
|
-
const activeContext = context.active();
|
|
595
|
-
const stored = contextStorage.getStore()?.value;
|
|
596
|
-
if (!stored) return activeContext;
|
|
597
|
-
const storedBaggage = propagation.getBaggage(stored);
|
|
598
|
-
return storedBaggage ? propagation.setBaggage(activeContext, storedBaggage) : activeContext;
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* Set a value in AsyncLocalStorage, preferring enterWith() when available
|
|
602
|
-
* (Node.js) and falling back to run() for environments that only support
|
|
603
|
-
* run() (e.g. Cloudflare Workers).
|
|
604
|
-
*
|
|
605
|
-
* On runtimes without enterWith() we mutate the existing run() scope when one
|
|
606
|
-
* exists. This is what allows baggage/correlation updates to remain visible
|
|
607
|
-
* for the rest of the traced callback in Workers.
|
|
608
|
-
*/
|
|
609
|
-
function enterOrRun(storage, value) {
|
|
610
|
-
const existingStore = storage.getStore();
|
|
611
|
-
if (existingStore) {
|
|
612
|
-
existingStore.value = value;
|
|
613
|
-
return;
|
|
454
|
+
enqueue(event) {
|
|
455
|
+
if (this.isShuttingDown) {
|
|
456
|
+
this.recordDropped("shutdown", event);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (this.queue.length >= this.config.maxSize) {
|
|
460
|
+
const droppedEvent = this.queue.shift();
|
|
461
|
+
this.recordDropped("rate_limit", droppedEvent);
|
|
462
|
+
getLogger().warn({ droppedEvent: droppedEvent?.name }, `[autotel] Events queue full (${this.config.maxSize} events). Dropping oldest event. Events are being produced faster than they can be sent. Check your subscribers or reduce tracking frequency.`);
|
|
463
|
+
}
|
|
464
|
+
const enrichedEvent = {
|
|
465
|
+
...event,
|
|
466
|
+
_correlationId: event._correlationId || getOrCreateCorrelationId()
|
|
467
|
+
};
|
|
468
|
+
this.queue.push(enrichedEvent);
|
|
469
|
+
this.scheduleBatchFlush();
|
|
614
470
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Schedule a batch flush if not already scheduled
|
|
473
|
+
*/
|
|
474
|
+
scheduleBatchFlush() {
|
|
475
|
+
if (this.flushTimer || this.flushPromise) return;
|
|
476
|
+
this.flushTimer = setTimeout(() => {
|
|
477
|
+
this.flushTimer = null;
|
|
478
|
+
this.flushBatch();
|
|
479
|
+
}, this.config.flushInterval);
|
|
620
480
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
481
|
+
/**
|
|
482
|
+
* Flush a batch of events
|
|
483
|
+
* Uses promise-based concurrency control to prevent race conditions
|
|
484
|
+
*/
|
|
485
|
+
async flushBatch() {
|
|
486
|
+
if (this.queue.length === 0) return;
|
|
487
|
+
if (this.flushPromise) {
|
|
488
|
+
await this.flushPromise;
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
this.flushPromise = this.doFlushBatch();
|
|
492
|
+
try {
|
|
493
|
+
await this.flushPromise;
|
|
494
|
+
} finally {
|
|
495
|
+
this.flushPromise = null;
|
|
496
|
+
if (this.queue.length > 0) this.scheduleBatchFlush();
|
|
497
|
+
}
|
|
630
498
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
* and provides span manipulation methods and baggage operations in a consistent format.
|
|
638
|
-
*
|
|
639
|
-
* Note: Baggage methods always operate on the currently active context,
|
|
640
|
-
* which may differ from the context when createTraceContext was called.
|
|
641
|
-
*/
|
|
642
|
-
function createTraceContext(span) {
|
|
643
|
-
const spanContext = span.spanContext();
|
|
644
|
-
if (!contextStorage.getStore()?.value) {
|
|
645
|
-
const activeContext = context.active();
|
|
646
|
-
enterOrRun(contextStorage, activeContext);
|
|
499
|
+
/**
|
|
500
|
+
* Internal flush implementation
|
|
501
|
+
*/
|
|
502
|
+
async doFlushBatch() {
|
|
503
|
+
const batch = this.queue.splice(0, this.config.batchSize);
|
|
504
|
+
await this.sendWithRetry(batch, this.config.maxRetries);
|
|
647
505
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Send events with exponential backoff retry
|
|
508
|
+
* Tracks per-event, per-subscriber failures so failed counter reflects actual failed deliveries.
|
|
509
|
+
* On retry, only failed (event, subscriber) pairs are re-sent to avoid double-counting delivered.
|
|
510
|
+
*/
|
|
511
|
+
async sendWithRetry(events, retriesLeft, subscribersByEventIndex) {
|
|
512
|
+
const failedDeliveries = await this.sendToSubscribers(events, subscribersByEventIndex);
|
|
513
|
+
if (failedDeliveries.length > 0) if (retriesLeft > 0) {
|
|
514
|
+
const failedEventIndicesOrdered = [...new Set(failedDeliveries.map((f) => f.eventIndex))].toSorted((a, b) => a - b);
|
|
515
|
+
const eventsToRetry = failedEventIndicesOrdered.map((i) => events[i]);
|
|
516
|
+
const failedSubscribersByRetryIndex = /* @__PURE__ */ new Map();
|
|
517
|
+
for (const [j, origIndex] of failedEventIndicesOrdered.entries()) {
|
|
518
|
+
const set = /* @__PURE__ */ new Set();
|
|
519
|
+
for (const { eventIndex, subscriberName } of failedDeliveries) if (eventIndex === origIndex) set.add(subscriberName);
|
|
520
|
+
failedSubscribersByRetryIndex.set(j, set);
|
|
521
|
+
}
|
|
522
|
+
const delay = Math.pow(2, this.config.maxRetries - retriesLeft) * 1e3;
|
|
523
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
524
|
+
return this.sendWithRetry(eventsToRetry, retriesLeft - 1, failedSubscribersByRetryIndex);
|
|
525
|
+
} else {
|
|
526
|
+
for (const { eventIndex, subscriberName, error } of failedDeliveries) {
|
|
527
|
+
const event = events[eventIndex];
|
|
528
|
+
if (event) this.recordFailed(event, subscriberName, error);
|
|
529
|
+
}
|
|
530
|
+
const failedSubscriberNames = [...new Set(failedDeliveries.map((f) => f.subscriberName))];
|
|
531
|
+
getLogger().error({
|
|
532
|
+
failedSubscribers: failedSubscriberNames,
|
|
533
|
+
retriesAttempted: this.config.maxRetries
|
|
534
|
+
}, "[autotel] Failed to send events after retries");
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Send events to configured subscribers with rate limiting and metrics.
|
|
539
|
+
* When subscribersByEventIndex is provided (retry path), only those subscribers are tried per event.
|
|
540
|
+
* Returns per-event, per-subscriber failures (empty if all succeeded).
|
|
541
|
+
*/
|
|
542
|
+
async sendToSubscribers(events, subscribersByEventIndex) {
|
|
543
|
+
const failedDeliveries = [];
|
|
544
|
+
const sendOne = async (event, eventIndex) => {
|
|
545
|
+
const subscriberNames = subscribersByEventIndex?.get(eventIndex);
|
|
546
|
+
const failures = await this.sendEventToSubscribers(event, subscriberNames ?? void 0);
|
|
547
|
+
for (const failure of failures) failedDeliveries.push({
|
|
548
|
+
eventIndex,
|
|
549
|
+
subscriberName: failure.subscriberName,
|
|
550
|
+
error: failure.error
|
|
551
|
+
});
|
|
552
|
+
};
|
|
553
|
+
if (!this.rateLimiter) {
|
|
554
|
+
for (const [i, event] of events.entries()) if (event) await sendOne(event, i);
|
|
555
|
+
return failedDeliveries;
|
|
556
|
+
}
|
|
557
|
+
for (const [i, event] of events.entries()) {
|
|
558
|
+
await this.rateLimiter.waitForToken();
|
|
559
|
+
if (event) await sendOne(event, i);
|
|
560
|
+
}
|
|
561
|
+
return failedDeliveries;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Send a single event to subscribers.
|
|
565
|
+
* - When subscriberNames is undefined (initial attempt): send to all subscribers.
|
|
566
|
+
* - When subscriberNames is provided (retry): send only to those subscribers (never re-send to healthy ones).
|
|
567
|
+
* Returns list of subscribers that failed (empty if all succeeded).
|
|
568
|
+
*/
|
|
569
|
+
async sendEventToSubscribers(event, subscriberNames) {
|
|
570
|
+
const startTime = event.timestamp;
|
|
571
|
+
const failures = [];
|
|
572
|
+
const subscribersToTry = subscriberNames === void 0 ? this.subscribers : this.subscribers.filter((s) => subscriberNames.has(getSubscriberName(s)));
|
|
573
|
+
const results = await Promise.allSettled(subscribersToTry.map(async (subscriber) => {
|
|
574
|
+
const subscriberName = getSubscriberName(subscriber);
|
|
575
|
+
try {
|
|
576
|
+
await subscriber.trackEvent(event.name, event.attributes, {
|
|
577
|
+
autotel: event.autotel,
|
|
578
|
+
schema: event.schema
|
|
579
|
+
});
|
|
580
|
+
this.recordDelivered(event, subscriberName, startTime);
|
|
581
|
+
return {
|
|
582
|
+
subscriberName,
|
|
583
|
+
success: true
|
|
584
|
+
};
|
|
585
|
+
} catch (error) {
|
|
586
|
+
this.markSubscriberUnhealthy(subscriberName);
|
|
587
|
+
return {
|
|
588
|
+
subscriberName,
|
|
589
|
+
success: false,
|
|
590
|
+
error: error instanceof Error ? error : void 0
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
}));
|
|
594
|
+
for (const result of results) if (result.status === "fulfilled" && !result.value.success) failures.push({
|
|
595
|
+
subscriberName: result.value.subscriberName,
|
|
596
|
+
error: result.value.error
|
|
597
|
+
});
|
|
598
|
+
return failures;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Flush all remaining events. Queue remains usable after flush (e.g. for
|
|
602
|
+
* auto-flush at root span end). Use shutdown() when tearing down the queue.
|
|
603
|
+
*/
|
|
604
|
+
async flush() {
|
|
605
|
+
if (this.flushTimer) {
|
|
606
|
+
clearTimeout(this.flushTimer);
|
|
607
|
+
this.flushTimer = null;
|
|
608
|
+
}
|
|
609
|
+
if (this.flushPromise) await this.flushPromise;
|
|
610
|
+
while (this.queue.length > 0) await this.doFlushBatch();
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Flush remaining events and permanently disable the queue (reject new events).
|
|
614
|
+
* Use for process/SDK shutdown; use flush() for periodic or span-end drain.
|
|
615
|
+
*/
|
|
616
|
+
async shutdown() {
|
|
617
|
+
this.isShuttingDown = true;
|
|
618
|
+
await this.flush();
|
|
619
|
+
const drains = await Promise.allSettled(this.subscribers.map(async (subscriber) => {
|
|
620
|
+
if (!subscriber.shutdown) return;
|
|
621
|
+
shutDownSubscribers.add(subscriber);
|
|
622
|
+
return subscriber.shutdown();
|
|
623
|
+
}));
|
|
624
|
+
for (const [index, drain] of drains.entries()) {
|
|
625
|
+
if (drain.status !== "rejected") continue;
|
|
626
|
+
const subscriberName = getSubscriberName(this.subscribers[index]);
|
|
627
|
+
this.subscriberHealthy.set(subscriberName, false);
|
|
628
|
+
getLogger().error({
|
|
629
|
+
subscriber: subscriberName,
|
|
630
|
+
err: drain.reason
|
|
631
|
+
}, "[autotel] Subscriber shutdown failed, buffered events may be lost");
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Cleanup observable metric callbacks to prevent memory leaks
|
|
636
|
+
* Call this when destroying the EventQueue instance
|
|
637
|
+
*/
|
|
638
|
+
cleanup() {
|
|
639
|
+
for (const cleanupFn of this.observableCleanups) try {
|
|
640
|
+
cleanupFn();
|
|
641
|
+
} catch {}
|
|
642
|
+
this.observableCleanups = [];
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Get queue size (for testing/debugging)
|
|
646
|
+
*/
|
|
647
|
+
size() {
|
|
648
|
+
return this.queue.length;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Get subscriber health status (for testing/debugging)
|
|
652
|
+
*/
|
|
653
|
+
getSubscriberHealth() {
|
|
654
|
+
return new Map(this.subscriberHealthy);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Check if a specific subscriber is healthy
|
|
658
|
+
*/
|
|
659
|
+
isSubscriberHealthy(subscriberName) {
|
|
660
|
+
return this.subscriberHealthy.get(subscriberName.toLowerCase()) ?? true;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Manually mark a subscriber as healthy or unhealthy
|
|
664
|
+
* (used for circuit breaker integration)
|
|
665
|
+
*/
|
|
666
|
+
setSubscriberHealth(subscriberName, healthy) {
|
|
667
|
+
this.subscriberHealthy.set(subscriberName.toLowerCase(), healthy);
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
//#endregion
|
|
672
|
+
//#region src/validation.ts
|
|
673
|
+
const DEFAULT_CONFIG$1 = {
|
|
674
|
+
maxEventNameLength: 100,
|
|
675
|
+
maxAttributeKeyLength: 100,
|
|
676
|
+
maxAttributeValueLength: 1e3,
|
|
677
|
+
maxAttributeCount: 50,
|
|
678
|
+
maxNestingDepth: 3,
|
|
679
|
+
sensitivePatterns: [
|
|
680
|
+
/password/i,
|
|
681
|
+
/secret/i,
|
|
682
|
+
/token/i,
|
|
683
|
+
/api[_-]?key/i,
|
|
684
|
+
/access[_-]?key/i,
|
|
685
|
+
/private[_-]?key/i,
|
|
686
|
+
/auth/i,
|
|
687
|
+
/credential/i,
|
|
688
|
+
/ssn/i,
|
|
689
|
+
/credit[_-]?card/i
|
|
690
|
+
]
|
|
691
|
+
};
|
|
692
|
+
var ValidationError = class extends Error {
|
|
693
|
+
constructor(message) {
|
|
694
|
+
super(message);
|
|
695
|
+
this.name = "ValidationError";
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
/**
|
|
699
|
+
* Validate and sanitize event name
|
|
700
|
+
* Throws ValidationError if invalid
|
|
701
|
+
*/
|
|
702
|
+
function validateEventName(eventName, config = DEFAULT_CONFIG$1) {
|
|
703
|
+
if (asString(eventName) === void 0) throw new ValidationError(`Event name must be a string, got ${describeValue(eventName)}`);
|
|
704
|
+
const trimmed = eventName.trim();
|
|
705
|
+
if (trimmed.length === 0) throw new ValidationError("Event name cannot be empty");
|
|
706
|
+
if (trimmed.length > config.maxEventNameLength) throw new ValidationError(`Event name too long (${trimmed.length} chars). Max: ${config.maxEventNameLength}`);
|
|
707
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) throw new ValidationError(`Event name contains invalid characters: "${trimmed}". Use only letters, numbers, dots, underscores, and hyphens.`);
|
|
708
|
+
return trimmed;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Validate and sanitize attributes
|
|
712
|
+
* Returns sanitized attributes (sensitive data redacted)
|
|
713
|
+
*/
|
|
714
|
+
function validateAttributes(attributes, config = DEFAULT_CONFIG$1) {
|
|
715
|
+
if (attributes === void 0 || attributes === null) return;
|
|
716
|
+
if (!asRecord(attributes)) throw new ValidationError("Attributes must be an object");
|
|
717
|
+
const keys = Object.keys(attributes);
|
|
718
|
+
if (keys.length > config.maxAttributeCount) throw new ValidationError(`Too many attributes (${keys.length}). Max: ${config.maxAttributeCount}`);
|
|
719
|
+
const sanitized = {};
|
|
720
|
+
for (const key of keys) {
|
|
721
|
+
if (key.length > config.maxAttributeKeyLength) throw new ValidationError(`Attribute key too long: "${key.slice(0, 20)}..." (${key.length} chars). Max: ${config.maxAttributeKeyLength}`);
|
|
722
|
+
const value = attributes[key];
|
|
723
|
+
if (isSensitiveString(key, value, config)) {
|
|
724
|
+
sanitized[key] = "[REDACTED]";
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
sanitized[key] = sanitizeValue(value, config, 1);
|
|
728
|
+
}
|
|
729
|
+
return sanitized;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Whether this key/value pair is a credential to redact. Strings only:
|
|
733
|
+
* numeric and boolean values are not credentials, and replacing them with the
|
|
734
|
+
* literal '[REDACTED]' leaks no signal while breaking every consumer that
|
|
735
|
+
* treats them as a number.
|
|
736
|
+
*/
|
|
737
|
+
function isSensitiveString(key, value, config) {
|
|
738
|
+
return asString(value) !== void 0 && config.sensitivePatterns.some((pattern) => pattern.test(key));
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Sanitize attribute value (recursive)
|
|
742
|
+
*/
|
|
743
|
+
function sanitizeValue(value, config, depth) {
|
|
744
|
+
if (depth > config.maxNestingDepth) return "[MAX_DEPTH_EXCEEDED]";
|
|
745
|
+
if (value === null || value === void 0) return value;
|
|
746
|
+
const text = asString(value);
|
|
747
|
+
if (text !== void 0) return text.length > config.maxAttributeValueLength ? text.slice(0, config.maxAttributeValueLength) + "..." : text;
|
|
748
|
+
const scalar = asNumber(value) ?? asBoolean(value);
|
|
749
|
+
if (scalar !== void 0) return scalar;
|
|
750
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, config, depth + 1));
|
|
751
|
+
const record = asRecord(value);
|
|
752
|
+
if (record) try {
|
|
753
|
+
JSON.stringify(record);
|
|
754
|
+
const sanitized = {};
|
|
755
|
+
for (const [key, nested] of Object.entries(record)) sanitized[key] = isSensitiveString(key, nested, config) ? "[REDACTED]" : sanitizeValue(nested, config, depth + 1);
|
|
756
|
+
return sanitized;
|
|
757
|
+
} catch {
|
|
758
|
+
return "[CIRCULAR]";
|
|
759
|
+
}
|
|
760
|
+
return `[${describeValue(value)}]`;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Validate and sanitize an events event
|
|
764
|
+
* Returns { eventName, attributes } with sanitized values
|
|
765
|
+
*/
|
|
766
|
+
function validateEvent(eventName, attributes, config) {
|
|
767
|
+
const fullConfig = {
|
|
768
|
+
...DEFAULT_CONFIG$1,
|
|
769
|
+
...config
|
|
770
|
+
};
|
|
771
|
+
return {
|
|
772
|
+
eventName: validateEventName(eventName, fullConfig),
|
|
773
|
+
attributes: validateAttributes(attributes, fullConfig)
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region src/track.ts
|
|
779
|
+
/**
|
|
780
|
+
* Global track() function for business events
|
|
781
|
+
*
|
|
782
|
+
* Simple, no instantiation needed, auto-attaches trace context
|
|
783
|
+
*/
|
|
784
|
+
let eventsQueue = null;
|
|
785
|
+
/**
|
|
786
|
+
* Build autotel event context for trace correlation
|
|
787
|
+
*
|
|
788
|
+
* Works in multiple contexts:
|
|
789
|
+
* 1. Inside a span → use current span's trace_id + span_id
|
|
790
|
+
* 2. Outside span → use correlation_id only
|
|
791
|
+
* 3. With trace URL config → include clickable trace URL
|
|
792
|
+
*/
|
|
793
|
+
function buildAutotelContext(span) {
|
|
794
|
+
const eventsConfig = getEventsConfig();
|
|
795
|
+
const config = getConfig();
|
|
796
|
+
const correlationId = getOrCreateCorrelationId();
|
|
797
|
+
if (!eventsConfig?.includeTraceContext) return { correlation_id: correlationId };
|
|
798
|
+
const autotelContext = { correlation_id: correlationId };
|
|
799
|
+
const spanContext = span?.spanContext();
|
|
800
|
+
if (spanContext) {
|
|
801
|
+
autotelContext.trace_id = spanContext.traceId;
|
|
802
|
+
autotelContext.span_id = spanContext.spanId;
|
|
803
|
+
autotelContext.trace_flags = spanContext.traceFlags.toString(16).padStart(2, "0");
|
|
804
|
+
const traceState = spanContext.traceState;
|
|
805
|
+
if (traceState) try {
|
|
806
|
+
if (typeof traceState.serialize === "function") {
|
|
807
|
+
const traceStateStr = traceState.serialize();
|
|
808
|
+
if (traceStateStr) autotelContext.trace_state = traceStateStr;
|
|
809
|
+
}
|
|
810
|
+
} catch {}
|
|
811
|
+
if (eventsConfig.traceUrl && config) {
|
|
812
|
+
const traceUrl = eventsConfig.traceUrl({
|
|
813
|
+
traceId: spanContext.traceId,
|
|
814
|
+
spanId: spanContext.spanId,
|
|
815
|
+
correlationId,
|
|
816
|
+
serviceName: config.service,
|
|
817
|
+
environment: config.environment
|
|
818
|
+
});
|
|
819
|
+
if (traceUrl) autotelContext.trace_url = traceUrl;
|
|
820
|
+
}
|
|
821
|
+
} else if (eventsConfig.traceUrl && config) {
|
|
822
|
+
const traceUrl = eventsConfig.traceUrl({
|
|
823
|
+
correlationId,
|
|
824
|
+
serviceName: config.service,
|
|
825
|
+
environment: config.environment
|
|
826
|
+
});
|
|
827
|
+
if (traceUrl) autotelContext.trace_url = traceUrl;
|
|
828
|
+
}
|
|
829
|
+
return autotelContext;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Initialize events queue lazily
|
|
833
|
+
*/
|
|
834
|
+
function getOrCreateQueue() {
|
|
835
|
+
if (!isInitialized()) {
|
|
836
|
+
warnIfNotInitialized("track()");
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
if (!eventsQueue) {
|
|
840
|
+
const config = getConfig();
|
|
841
|
+
if (!config?.subscribers || config.subscribers.length === 0) return null;
|
|
842
|
+
eventsQueue = new EventQueue(config.subscribers);
|
|
843
|
+
}
|
|
844
|
+
return eventsQueue;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Track a business events event
|
|
848
|
+
*
|
|
849
|
+
* Features:
|
|
850
|
+
* - Auto-attaches traceId and spanId if in active span
|
|
851
|
+
* - Batched sending with retry
|
|
852
|
+
* - Type-safe with optional generic
|
|
853
|
+
* - No-op if init() not called or no subscribers configured
|
|
854
|
+
*
|
|
855
|
+
* @example Basic usage
|
|
856
|
+
* ```typescript
|
|
857
|
+
* track('user.signup', { userId: '123', plan: 'pro' })
|
|
858
|
+
* ```
|
|
859
|
+
*
|
|
860
|
+
* @example With type safety
|
|
861
|
+
* ```typescript
|
|
862
|
+
* interface EventDatas {
|
|
863
|
+
* 'user.signup': { userId: string; plan: string }
|
|
864
|
+
* 'plan.upgraded': { userId: string; revenue: number }
|
|
865
|
+
* }
|
|
866
|
+
*
|
|
867
|
+
* track<EventDatas>('user.signup', { userId: '123', plan: 'pro' })
|
|
868
|
+
* ```
|
|
869
|
+
*
|
|
870
|
+
* @example Trace correlation (automatic)
|
|
871
|
+
* ```typescript
|
|
872
|
+
* @Instrumented()
|
|
873
|
+
* class UserService {
|
|
874
|
+
* async createUser(data: CreateUserData) {
|
|
875
|
+
* // This track call automatically includes traceId + spanId
|
|
876
|
+
* track('user.signup', { userId: data.id })
|
|
877
|
+
* }
|
|
878
|
+
* }
|
|
879
|
+
* ```
|
|
880
|
+
*/
|
|
881
|
+
function track(event, data, options) {
|
|
882
|
+
const queue = getOrCreateQueue();
|
|
883
|
+
if (!queue) return;
|
|
884
|
+
const validated = validateEvent(event, data, getValidationConfig() || void 0);
|
|
885
|
+
const span = trace.getActiveSpan();
|
|
886
|
+
const enrichedData = span ? {
|
|
887
|
+
...validated.attributes,
|
|
888
|
+
traceId: span.spanContext().traceId,
|
|
889
|
+
spanId: span.spanContext().spanId
|
|
890
|
+
} : validated.attributes;
|
|
891
|
+
const autotelContext = buildAutotelContext(span);
|
|
892
|
+
queue.enqueue({
|
|
893
|
+
name: validated.eventName,
|
|
894
|
+
attributes: enrichedData,
|
|
895
|
+
timestamp: Date.now(),
|
|
896
|
+
autotel: autotelContext,
|
|
897
|
+
schema: options?.schema
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Get events queue (for flush/shutdown)
|
|
902
|
+
* @internal
|
|
903
|
+
*/
|
|
904
|
+
function getEventQueue() {
|
|
905
|
+
return eventsQueue;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Reset events queue (for shutdown/cleanup)
|
|
909
|
+
* @internal
|
|
910
|
+
*/
|
|
911
|
+
function resetEventQueue() {
|
|
912
|
+
eventsQueue = null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
//#endregion
|
|
916
|
+
//#region src/trace-context.ts
|
|
917
|
+
const spansWithExplicitStatus = /* @__PURE__ */ new WeakSet();
|
|
918
|
+
/** Whether a TraceContext consumer explicitly chose this span's status. */
|
|
919
|
+
function hasExplicitSpanStatus(span) {
|
|
920
|
+
return spansWithExplicitStatus.has(span);
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* AsyncLocalStorage for storing the active context with baggage
|
|
924
|
+
* This allows setters to update the context and have it persist
|
|
925
|
+
*/
|
|
926
|
+
const contextStorage = new nodeAsyncHooks.AsyncLocalStorage();
|
|
927
|
+
/**
|
|
928
|
+
* Get the context storage instance (for initialization in functional.ts)
|
|
929
|
+
*/
|
|
930
|
+
function getContextStorage() {
|
|
931
|
+
return contextStorage;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Get the active OTel context with the latest stored baggage overlaid.
|
|
935
|
+
* Span identity always comes from the active OTel scope.
|
|
936
|
+
*/
|
|
937
|
+
function getActiveContextWithBaggage() {
|
|
938
|
+
const activeContext = context.active();
|
|
939
|
+
const stored = contextStorage.getStore()?.value;
|
|
940
|
+
if (!stored) return activeContext;
|
|
941
|
+
const storedBaggage = propagation.getBaggage(stored);
|
|
942
|
+
return storedBaggage ? propagation.setBaggage(activeContext, storedBaggage) : activeContext;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Set a value in AsyncLocalStorage, preferring enterWith() when available
|
|
946
|
+
* (Node.js) and falling back to run() for environments that only support
|
|
947
|
+
* run() (e.g. Cloudflare Workers).
|
|
948
|
+
*
|
|
949
|
+
* On runtimes without enterWith() we mutate the existing run() scope when one
|
|
950
|
+
* exists. This is what allows baggage/correlation updates to remain visible
|
|
951
|
+
* for the rest of the traced callback in Workers.
|
|
952
|
+
*/
|
|
953
|
+
function enterOrRun(storage, value) {
|
|
954
|
+
const existingStore = storage.getStore();
|
|
955
|
+
if (existingStore) {
|
|
956
|
+
existingStore.value = value;
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const boxedValue = { value };
|
|
960
|
+
try {
|
|
961
|
+
storage.enterWith(boxedValue);
|
|
962
|
+
} catch {
|
|
963
|
+
storage.run(boxedValue, () => {});
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function updateActiveContext(newContext) {
|
|
967
|
+
enterOrRun(contextStorage, newContext);
|
|
968
|
+
const manager = context._getContextManager?.();
|
|
969
|
+
if (!manager) return;
|
|
970
|
+
const asyncLocal = manager._asyncLocalStorage ?? void 0;
|
|
971
|
+
if (asyncLocal?.enterWith) {
|
|
972
|
+
asyncLocal.enterWith(newContext);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (isFunction(manager.with)) manager.with(newContext, () => {});
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Create a TraceContext from an OpenTelemetry Span
|
|
979
|
+
*
|
|
980
|
+
* This utility extracts trace context information from a span
|
|
981
|
+
* and provides span manipulation methods and baggage operations in a consistent format.
|
|
982
|
+
*
|
|
983
|
+
* Note: Baggage methods always operate on the currently active context,
|
|
984
|
+
* which may differ from the context when createTraceContext was called.
|
|
985
|
+
*/
|
|
986
|
+
function createTraceContext(span) {
|
|
987
|
+
const spanContext = span.spanContext();
|
|
988
|
+
if (!contextStorage.getStore()?.value) {
|
|
989
|
+
const activeContext = context.active();
|
|
990
|
+
enterOrRun(contextStorage, activeContext);
|
|
991
|
+
}
|
|
992
|
+
const traceCtx = {
|
|
993
|
+
traceId: spanContext.traceId,
|
|
994
|
+
spanId: spanContext.spanId,
|
|
995
|
+
correlationId: spanContext.traceId.slice(0, 16),
|
|
996
|
+
setAttribute: span.setAttribute.bind(span),
|
|
997
|
+
setAttributes: span.setAttributes.bind(span),
|
|
998
|
+
setStatus: (status) => {
|
|
999
|
+
spansWithExplicitStatus.add(span);
|
|
1000
|
+
span.setStatus(status);
|
|
1001
|
+
},
|
|
1002
|
+
recordException: span.recordException.bind(span),
|
|
1003
|
+
addEvent: span.addEvent.bind(span),
|
|
1004
|
+
addLink: span.addLink.bind(span),
|
|
1005
|
+
addLinks: span.addLinks.bind(span),
|
|
1006
|
+
updateName: span.updateName.bind(span),
|
|
1007
|
+
isRecording: span.isRecording.bind(span),
|
|
1008
|
+
recordError: (cause) => {
|
|
1009
|
+
const err = cause instanceof Error ? cause : new Error(String(cause));
|
|
1010
|
+
recordStructuredError(traceCtx, err);
|
|
1011
|
+
},
|
|
1012
|
+
track: (event, data) => {
|
|
1013
|
+
track(event, data);
|
|
1014
|
+
},
|
|
1015
|
+
getBaggage(key) {
|
|
1016
|
+
const activeCtx = context.active();
|
|
1017
|
+
let baggage = propagation.getBaggage(activeCtx);
|
|
1018
|
+
if (!baggage) {
|
|
1019
|
+
const storedContext = contextStorage.getStore()?.value;
|
|
1020
|
+
if (storedContext) baggage = propagation.getBaggage(storedContext);
|
|
1021
|
+
}
|
|
1022
|
+
return baggage?.getEntry(key)?.value;
|
|
1023
|
+
},
|
|
1024
|
+
setBaggage(key, value) {
|
|
1025
|
+
const currentContext = getActiveContextWithBaggage();
|
|
1026
|
+
const updated = (propagation.getBaggage(currentContext) ?? propagation.createBaggage()).setEntry(key, { value });
|
|
1027
|
+
updateActiveContext(propagation.setBaggage(currentContext, updated));
|
|
1028
|
+
return value;
|
|
1029
|
+
},
|
|
1030
|
+
deleteBaggage(key) {
|
|
1031
|
+
const currentContext = getActiveContextWithBaggage();
|
|
1032
|
+
const baggage = propagation.getBaggage(currentContext);
|
|
1033
|
+
if (baggage) {
|
|
1034
|
+
const updated = baggage.removeEntry(key);
|
|
1035
|
+
updateActiveContext(propagation.setBaggage(currentContext, updated));
|
|
692
1036
|
}
|
|
693
1037
|
},
|
|
694
1038
|
getAllBaggage() {
|
|
@@ -818,820 +1162,1345 @@ function defineBaggageSchema(namespace) {
|
|
|
818
1162
|
}
|
|
819
1163
|
|
|
820
1164
|
//#endregion
|
|
821
|
-
//#region src/
|
|
822
|
-
/**
|
|
823
|
-
* Correlation ID utilities for event-driven observability
|
|
824
|
-
*
|
|
825
|
-
* Provides a stable join key across events, logs, and spans even when traces fragment.
|
|
826
|
-
* Format: 16 hex chars (64 bits), crypto-random, URL-safe.
|
|
827
|
-
*
|
|
828
|
-
* Lifecycle:
|
|
829
|
-
* 1. Generated at boundary root (HTTP server span, message process span, cron job span)
|
|
830
|
-
* 2. Reused within context (nested work shares it via AsyncLocalStorage)
|
|
831
|
-
* 3. Propagated via baggage (optional, default OFF to avoid header bloat)
|
|
832
|
-
*
|
|
833
|
-
* @example Basic usage
|
|
834
|
-
* ```typescript
|
|
835
|
-
* import { generateCorrelationId, getCorrelationId } from 'autotel/correlation-id';
|
|
836
|
-
*
|
|
837
|
-
* // Generate a new correlation ID
|
|
838
|
-
* const id = generateCorrelationId();
|
|
839
|
-
* // Returns: 'a1b2c3d4e5f67890'
|
|
840
|
-
*
|
|
841
|
-
* // Get current correlation ID from context
|
|
842
|
-
* const currentId = getCorrelationId();
|
|
843
|
-
* ```
|
|
844
|
-
*/
|
|
845
|
-
/**
|
|
846
|
-
* AsyncLocalStorage for storing correlation ID
|
|
847
|
-
* This allows correlation IDs to persist across async boundaries
|
|
848
|
-
*/
|
|
849
|
-
const correlationStorage = new nodeAsyncHooks.AsyncLocalStorage();
|
|
850
|
-
/**
|
|
851
|
-
* Baggage key for correlation ID propagation
|
|
852
|
-
*/
|
|
853
|
-
const CORRELATION_ID_BAGGAGE_KEY = "autotel.correlation_id";
|
|
854
|
-
/**
|
|
855
|
-
* Generate a new correlation ID
|
|
856
|
-
*
|
|
857
|
-
* Format: 16 hex chars (64 bits), crypto-random, URL-safe
|
|
858
|
-
*
|
|
859
|
-
* @returns A new correlation ID
|
|
860
|
-
*
|
|
861
|
-
* @example
|
|
862
|
-
* ```typescript
|
|
863
|
-
* const id = generateCorrelationId();
|
|
864
|
-
* // Returns: 'a1b2c3d4e5f67890'
|
|
865
|
-
* ```
|
|
866
|
-
*/
|
|
867
|
-
function generateCorrelationId() {
|
|
868
|
-
const bytes = /* @__PURE__ */ new Uint8Array(8);
|
|
869
|
-
crypto.getRandomValues(bytes);
|
|
870
|
-
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
871
|
-
}
|
|
1165
|
+
//#region src/sampling.ts
|
|
872
1166
|
/**
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
* Resolution order:
|
|
876
|
-
* 1. AsyncLocalStorage (from explicit setCorrelationId or runWithCorrelationId)
|
|
877
|
-
* 2. Baggage (if propagated from upstream)
|
|
878
|
-
* 3. Active span's trace ID (first 16 chars as fallback)
|
|
879
|
-
* 4. undefined (if not in any context)
|
|
880
|
-
*
|
|
881
|
-
* @returns Current correlation ID or undefined
|
|
882
|
-
*
|
|
883
|
-
* @example
|
|
884
|
-
* ```typescript
|
|
885
|
-
* const id = getCorrelationId();
|
|
886
|
-
* if (id) {
|
|
887
|
-
* console.log('Correlation ID:', id);
|
|
888
|
-
* }
|
|
889
|
-
* ```
|
|
1167
|
+
* Tail sampling attribute keys (autotel-internal, not OTel semconv)
|
|
890
1168
|
*/
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
if (storedId) return storedId;
|
|
894
|
-
const activeContext = context.active();
|
|
895
|
-
const baggageEntry = propagation.getBaggage(activeContext)?.getEntry(CORRELATION_ID_BAGGAGE_KEY);
|
|
896
|
-
if (baggageEntry?.value) return baggageEntry.value;
|
|
897
|
-
const span = trace.getActiveSpan();
|
|
898
|
-
if (span) return span.spanContext().traceId.slice(0, 16);
|
|
899
|
-
}
|
|
1169
|
+
const AUTOTEL_SAMPLING_TAIL_KEEP = "autotel.sampling.tail.keep";
|
|
1170
|
+
const AUTOTEL_SAMPLING_TAIL_EVALUATED = "autotel.sampling.tail.evaluated";
|
|
900
1171
|
/**
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
* @returns Existing or new correlation ID
|
|
907
|
-
*
|
|
908
|
-
* @example
|
|
909
|
-
* ```typescript
|
|
910
|
-
* const id = getOrCreateCorrelationId();
|
|
911
|
-
* // Always returns a valid correlation ID
|
|
912
|
-
* ```
|
|
1172
|
+
* How many events each kept event stands for, expressed as "1 in N".
|
|
1173
|
+
*
|
|
1174
|
+
* A query that counts sampled spans undercounts the population. Multiplying
|
|
1175
|
+
* each kept event by this rate restores the estimate. Autotel records the
|
|
1176
|
+
* attribute only when N exceeds 1, so fully captured spans stay clean.
|
|
913
1177
|
*/
|
|
914
|
-
|
|
915
|
-
return getCorrelationId() ?? generateCorrelationId();
|
|
916
|
-
}
|
|
1178
|
+
const AUTOTEL_SAMPLING_RATE = "autotel.sampling.rate";
|
|
917
1179
|
/**
|
|
918
|
-
*
|
|
1180
|
+
* Spans an explicit `forceKeep()` claimed.
|
|
919
1181
|
*
|
|
920
|
-
* The
|
|
921
|
-
*
|
|
1182
|
+
* The tracing wrapper writes the sampler's tail verdict once the body has
|
|
1183
|
+
* run, which is after any `forceKeep()` inside it. Without this the verdict
|
|
1184
|
+
* overwrites the override and the span the caller insisted on is dropped.
|
|
1185
|
+
*/
|
|
1186
|
+
/**
|
|
1187
|
+
* Baggage key that turns on full-fidelity capture for a request.
|
|
922
1188
|
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
1189
|
+
* Baggage arrives on the request and propagates, so a gateway, a proxy, a
|
|
1190
|
+
* feature flag or a curl can turn this on for one user and it follows them
|
|
1191
|
+
* across every service. Nobody deploys anything to debug a live problem.
|
|
1192
|
+
*/
|
|
1193
|
+
const AUTOTEL_DEBUG_BAGGAGE_KEY = "autotel.debug";
|
|
1194
|
+
const forceKeptSpans = /* @__PURE__ */ new WeakSet();
|
|
1195
|
+
function markForceKept(span) {
|
|
1196
|
+
forceKeptSpans.add(span);
|
|
1197
|
+
}
|
|
1198
|
+
function isForceKept(span) {
|
|
1199
|
+
return forceKeptSpans.has(span);
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Convert a keep probability into the "1 in N" rate reported on spans.
|
|
926
1203
|
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
* await runWithCorrelationId('abc123', async () => {
|
|
930
|
-
* // getCorrelationId() returns 'abc123' here
|
|
931
|
-
* await processRequest();
|
|
932
|
-
* });
|
|
933
|
-
* ```
|
|
1204
|
+
* Keeping 10% of traces means each survivor stands for 10, so the two numbers
|
|
1205
|
+
* are reciprocals and easy to publish the wrong way round.
|
|
934
1206
|
*/
|
|
935
|
-
function
|
|
936
|
-
return
|
|
1207
|
+
function toSampleRate(probability) {
|
|
1208
|
+
return probability > 0 ? 1 / probability : 0;
|
|
937
1209
|
}
|
|
938
1210
|
/**
|
|
939
|
-
*
|
|
1211
|
+
* Map a string to a stable, evenly spread position in the unit interval.
|
|
940
1212
|
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
1213
|
+
* Two processes that hash the same key reach the same number, which is what
|
|
1214
|
+
* lets independent services agree on one trace's sampling decision.
|
|
943
1215
|
*
|
|
944
|
-
*
|
|
1216
|
+
* The spread matters as much as the stability. Real sampling keys share long
|
|
1217
|
+
* prefixes: `user_1000`, `user_1001`, `checkout-trace-0001`. A plain
|
|
1218
|
+
* multiply-and-add hash lets that shared prefix dominate the high bits, so a
|
|
1219
|
+
* whole key family lands in one narrow band and a rate of 0.1 keeps all of
|
|
1220
|
+
* them or none of them. FNV-1a followed by the murmur3 finalizer mixes the
|
|
1221
|
+
* low bits back through the word, so keys that differ in one character land
|
|
1222
|
+
* far apart.
|
|
1223
|
+
*/
|
|
1224
|
+
function hashUnitInterval(value) {
|
|
1225
|
+
let hash = 2166136261;
|
|
1226
|
+
for (let i = 0; i < value.length; i++) {
|
|
1227
|
+
hash ^= value.codePointAt(i) ?? 0;
|
|
1228
|
+
hash = Math.imul(hash, 16777619);
|
|
1229
|
+
}
|
|
1230
|
+
hash ^= hash >>> 16;
|
|
1231
|
+
hash = Math.imul(hash, 2246822507);
|
|
1232
|
+
hash ^= hash >>> 13;
|
|
1233
|
+
hash = Math.imul(hash, 3266489909);
|
|
1234
|
+
hash ^= hash >>> 16;
|
|
1235
|
+
return (hash >>> 0) / 4294967296;
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Simple random sampler
|
|
945
1239
|
*
|
|
946
1240
|
* @example
|
|
947
1241
|
* ```typescript
|
|
948
|
-
*
|
|
949
|
-
* // Now getCorrelationId() returns 'abc123'
|
|
1242
|
+
* new RandomSampler(0.1) // Sample 10% of requests
|
|
950
1243
|
* ```
|
|
951
1244
|
*/
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1245
|
+
var RandomSampler = class {
|
|
1246
|
+
rate;
|
|
1247
|
+
constructor(rate) {
|
|
1248
|
+
this.rate = rate;
|
|
1249
|
+
if (rate < 0 || rate > 1) throw new Error("Sample rate must be between 0 and 1");
|
|
1250
|
+
}
|
|
1251
|
+
shouldSample(_context) {
|
|
1252
|
+
return Math.random() < this.rate;
|
|
1253
|
+
}
|
|
1254
|
+
sampleRate() {
|
|
1255
|
+
return toSampleRate(this.rate);
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
955
1258
|
/**
|
|
956
|
-
*
|
|
1259
|
+
* Always sample (100% tracing)
|
|
1260
|
+
*/
|
|
1261
|
+
var AlwaysSampler = class {
|
|
1262
|
+
shouldSample(_context) {
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
/**
|
|
1267
|
+
* Never sample (0% tracing)
|
|
1268
|
+
*/
|
|
1269
|
+
var NeverSampler = class {
|
|
1270
|
+
shouldSample(_context) {
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
/**
|
|
1275
|
+
* Adaptive sampler that always traces errors and slow requests
|
|
957
1276
|
*
|
|
958
|
-
* This
|
|
959
|
-
*
|
|
1277
|
+
* This is the recommended sampler for production use.
|
|
1278
|
+
* It ensures you never miss critical issues while keeping costs down.
|
|
960
1279
|
*
|
|
961
|
-
*
|
|
962
|
-
*
|
|
1280
|
+
* Strategy:
|
|
1281
|
+
* - Always trace errors (critical for debugging)
|
|
1282
|
+
* - Always trace slow requests (performance issues)
|
|
1283
|
+
* - Use baseline sample rate for successful fast requests
|
|
963
1284
|
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
1285
|
+
* **IMPORTANT - Tail Sampling Requirement:**
|
|
1286
|
+
* This sampler uses tail sampling (makes decisions AFTER execution).
|
|
1287
|
+
* You MUST use TailSamplingSpanProcessor for it to work correctly:
|
|
1288
|
+
*
|
|
1289
|
+
* - If using initInstrumentation(): TailSamplingSpanProcessor is auto-configured
|
|
1290
|
+
* - If using custom TracerProvider: You MUST manually register TailSamplingSpanProcessor
|
|
1291
|
+
*
|
|
1292
|
+
* Without TailSamplingSpanProcessor, ALL spans are exported (defeating the cost savings).
|
|
1293
|
+
*
|
|
1294
|
+
* @see TailSamplingSpanProcessor
|
|
1295
|
+
* @see README.md "Tail Sampling with Custom Providers" section
|
|
966
1296
|
*
|
|
967
1297
|
* @example
|
|
968
1298
|
* ```typescript
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
* //
|
|
972
|
-
*
|
|
1299
|
+
* new AdaptiveSampler({
|
|
1300
|
+
* baselineSampleRate: 0.1, // 10% of normal requests
|
|
1301
|
+
* slowThresholdMs: 1000, // Requests > 1s are "slow"
|
|
1302
|
+
* alwaysSampleErrors: true, // Always trace errors
|
|
1303
|
+
* alwaysSampleSlow: true // Always trace slow requests
|
|
1304
|
+
* })
|
|
973
1305
|
* ```
|
|
974
1306
|
*/
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
*/
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1307
|
+
var AdaptiveSampler = class {
|
|
1308
|
+
baselineSampleRate;
|
|
1309
|
+
slowThresholdMs;
|
|
1310
|
+
alwaysSampleErrors;
|
|
1311
|
+
alwaysSampleSlow;
|
|
1312
|
+
linksBased;
|
|
1313
|
+
linksRate;
|
|
1314
|
+
logger;
|
|
1315
|
+
samplingDecisions = /* @__PURE__ */ new WeakMap();
|
|
1316
|
+
operationResults = /* @__PURE__ */ new WeakMap();
|
|
1317
|
+
constructor(options = {}) {
|
|
1318
|
+
this.baselineSampleRate = options.baselineSampleRate ?? .1;
|
|
1319
|
+
this.slowThresholdMs = options.slowThresholdMs ?? 1e3;
|
|
1320
|
+
this.alwaysSampleErrors = options.alwaysSampleErrors ?? true;
|
|
1321
|
+
this.alwaysSampleSlow = options.alwaysSampleSlow ?? true;
|
|
1322
|
+
this.linksBased = options.linksBased ?? false;
|
|
1323
|
+
this.linksRate = options.linksRate ?? 1;
|
|
1324
|
+
this.logger = options.logger;
|
|
1325
|
+
if (this.baselineSampleRate < 0 || this.baselineSampleRate > 1) throw new Error("Baseline sample rate must be between 0 and 1");
|
|
1326
|
+
if (this.linksRate < 0 || this.linksRate > 1) throw new Error("Links rate must be between 0 and 1");
|
|
1327
|
+
}
|
|
1328
|
+
needsTailSampling() {
|
|
1329
|
+
return true;
|
|
1330
|
+
}
|
|
1331
|
+
shouldSample(context) {
|
|
1332
|
+
const baselineDecision = Math.random() < this.baselineSampleRate;
|
|
1333
|
+
this.samplingDecisions.set(context.args, baselineDecision);
|
|
1334
|
+
return true;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Check if any links point to sampled spans.
|
|
1338
|
+
*
|
|
1339
|
+
* A span is considered linked to a sampled span if any of its links
|
|
1340
|
+
* have trace_flags with the sampled bit set (0x01).
|
|
1341
|
+
*
|
|
1342
|
+
* @param links - Array of span links to check
|
|
1343
|
+
* @returns true if any linked span is sampled, false otherwise
|
|
1344
|
+
*/
|
|
1345
|
+
hasSampledLink(links) {
|
|
1346
|
+
if (!links || links.length === 0) return false;
|
|
1347
|
+
return links.some((link) => link.context && (link.context.traceFlags & TraceFlags.SAMPLED) !== 0);
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Re-evaluate sampling decision after operation completes
|
|
1351
|
+
*
|
|
1352
|
+
* This allows us to always capture errors and slow requests,
|
|
1353
|
+
* even if they weren't initially sampled.
|
|
1354
|
+
*
|
|
1355
|
+
* @param context - Sampling context
|
|
1356
|
+
* @param result - Operation result
|
|
1357
|
+
* @returns true if this operation should be kept (not discarded)
|
|
1358
|
+
*/
|
|
1359
|
+
shouldKeepTrace(context, result) {
|
|
1360
|
+
const baselineDecision = this.samplingDecisions.get(context.args) ?? false;
|
|
1361
|
+
if (this.alwaysSampleErrors && !result.success) {
|
|
1362
|
+
if (!baselineDecision) this.logger?.debug({
|
|
1363
|
+
operation: context.operationName,
|
|
1364
|
+
error: result.error?.message
|
|
1365
|
+
}, "Adaptive sampling: Keeping error trace");
|
|
1366
|
+
return true;
|
|
1367
|
+
}
|
|
1368
|
+
if (this.alwaysSampleSlow && result.duration >= this.slowThresholdMs) {
|
|
1369
|
+
if (!baselineDecision) this.logger?.debug({
|
|
1370
|
+
operation: context.operationName,
|
|
1371
|
+
duration: result.duration
|
|
1372
|
+
}, "Adaptive sampling: Keeping slow trace");
|
|
1373
|
+
return true;
|
|
1374
|
+
}
|
|
1375
|
+
if (this.linksBased && context.links && this.hasSampledLink(context.links)) {
|
|
1376
|
+
const keepLinked = Math.random() < this.linksRate;
|
|
1377
|
+
if (keepLinked && !baselineDecision) this.logger?.debug({
|
|
1378
|
+
operation: context.operationName,
|
|
1379
|
+
linkCount: context.links.length
|
|
1380
|
+
}, "Adaptive sampling: Keeping trace due to sampled link");
|
|
1381
|
+
return keepLinked;
|
|
1382
|
+
}
|
|
1383
|
+
return baselineDecision;
|
|
998
1384
|
}
|
|
999
1385
|
};
|
|
1000
1386
|
/**
|
|
1001
|
-
*
|
|
1387
|
+
* User-based sampler for consistent tracing
|
|
1002
1388
|
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
* 2. Class static property (if available)
|
|
1006
|
-
* 3. Fallback: lowercase class name without "Subscriber" suffix
|
|
1007
|
-
*/
|
|
1008
|
-
function getSubscriberName(subscriber) {
|
|
1009
|
-
if (subscriber.name) return subscriber.name.toLowerCase();
|
|
1010
|
-
return (subscriber.constructor?.name || "unknown").replace(/Subscriber$/i, "").toLowerCase();
|
|
1011
|
-
}
|
|
1012
|
-
/**
|
|
1013
|
-
* Subscribers whose `shutdown()` has run. Terminal for the client they wrap, so
|
|
1014
|
-
* a queue rebuilt from the same config must not reuse them.
|
|
1015
|
-
*/
|
|
1016
|
-
const shutDownSubscribers = /* @__PURE__ */ new WeakSet();
|
|
1017
|
-
/**
|
|
1018
|
-
* Events queue with batching and backpressure
|
|
1389
|
+
* Always samples requests from specific user IDs.
|
|
1390
|
+
* Useful for debugging specific user issues or monitoring VIP users.
|
|
1019
1391
|
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1392
|
+
* @example
|
|
1393
|
+
* ```typescript
|
|
1394
|
+
* new UserIdSampler({
|
|
1395
|
+
* baselineSampleRate: 0.01, // 1% of normal users
|
|
1396
|
+
* alwaysSampleUsers: ['vip_123'], // Always trace VIP users
|
|
1397
|
+
* extractUserId: (args) => args[0]?.userId // Extract user ID from first arg
|
|
1398
|
+
* })
|
|
1399
|
+
* ```
|
|
1026
1400
|
*/
|
|
1027
|
-
var
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
const
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
this.rateLimiter = this.config.rateLimit ? new TokenBucketRateLimiter(this.config.rateLimit) : null;
|
|
1047
|
-
for (const subscriber of this.subscribers) {
|
|
1048
|
-
const name = getSubscriberName(subscriber);
|
|
1049
|
-
this.subscriberHealthy.set(name, true);
|
|
1401
|
+
var UserIdSampler = class {
|
|
1402
|
+
baselineSampleRate;
|
|
1403
|
+
alwaysSampleUsers;
|
|
1404
|
+
extractUserId;
|
|
1405
|
+
logger;
|
|
1406
|
+
constructor(options) {
|
|
1407
|
+
this.baselineSampleRate = options.baselineSampleRate ?? .1;
|
|
1408
|
+
this.alwaysSampleUsers = new Set(options.alwaysSampleUsers || []);
|
|
1409
|
+
this.extractUserId = options.extractUserId;
|
|
1410
|
+
this.logger = options.logger;
|
|
1411
|
+
}
|
|
1412
|
+
shouldSample(context) {
|
|
1413
|
+
const userId = this.extractUserId(context.args);
|
|
1414
|
+
if (userId && this.alwaysSampleUsers.has(userId)) {
|
|
1415
|
+
this.logger?.debug({
|
|
1416
|
+
operation: context.operationName,
|
|
1417
|
+
userId
|
|
1418
|
+
}, "Sampling user request");
|
|
1419
|
+
return true;
|
|
1050
1420
|
}
|
|
1051
|
-
this.
|
|
1421
|
+
if (userId) return this.hashString(userId) < this.baselineSampleRate;
|
|
1422
|
+
return Math.random() < this.baselineSampleRate;
|
|
1052
1423
|
}
|
|
1053
1424
|
/**
|
|
1054
|
-
*
|
|
1425
|
+
* Add user IDs to always-sample list
|
|
1055
1426
|
*/
|
|
1056
|
-
|
|
1057
|
-
const
|
|
1058
|
-
const queueSize = meter.createObservableGauge("autotel.event_delivery.queue.size", {
|
|
1059
|
-
description: "Current number of events in the delivery queue",
|
|
1060
|
-
unit: "count"
|
|
1061
|
-
});
|
|
1062
|
-
const queueSizeCallback = (observableResult) => {
|
|
1063
|
-
observableResult.observe(this.queue.length);
|
|
1064
|
-
};
|
|
1065
|
-
queueSize.addCallback(queueSizeCallback);
|
|
1066
|
-
this.observableCleanups.push(() => queueSize.removeCallback(queueSizeCallback));
|
|
1067
|
-
const oldestAge = meter.createObservableGauge("autotel.event_delivery.queue.oldest_age_ms", {
|
|
1068
|
-
description: "Age of the oldest event in the queue in milliseconds",
|
|
1069
|
-
unit: "ms"
|
|
1070
|
-
});
|
|
1071
|
-
const oldestAgeCallback = (observableResult) => {
|
|
1072
|
-
if (this.queue.length > 0) {
|
|
1073
|
-
const oldest = this.queue[0];
|
|
1074
|
-
const ageMs = Date.now() - oldest.timestamp;
|
|
1075
|
-
observableResult.observe(ageMs);
|
|
1076
|
-
} else observableResult.observe(0);
|
|
1077
|
-
};
|
|
1078
|
-
oldestAge.addCallback(oldestAgeCallback);
|
|
1079
|
-
this.observableCleanups.push(() => oldestAge.removeCallback(oldestAgeCallback));
|
|
1080
|
-
const delivered = meter.createCounter("autotel.event_delivery.queue.delivered", {
|
|
1081
|
-
description: "Number of events successfully delivered to subscribers",
|
|
1082
|
-
unit: "count"
|
|
1083
|
-
});
|
|
1084
|
-
const failed = meter.createCounter("autotel.event_delivery.queue.failed", {
|
|
1085
|
-
description: "Number of events that failed delivery after all retry attempts",
|
|
1086
|
-
unit: "count"
|
|
1087
|
-
});
|
|
1088
|
-
const dropped = meter.createCounter("autotel.event_delivery.queue.dropped", {
|
|
1089
|
-
description: "Number of events dropped from the queue",
|
|
1090
|
-
unit: "count"
|
|
1091
|
-
});
|
|
1092
|
-
const latency = meter.createHistogram("autotel.event_delivery.queue.latency_ms", {
|
|
1093
|
-
description: "Event delivery latency from enqueue to successful send",
|
|
1094
|
-
unit: "ms"
|
|
1095
|
-
});
|
|
1096
|
-
const subscriberHealth = meter.createObservableGauge("autotel.event_delivery.subscriber.health", {
|
|
1097
|
-
description: "Subscriber health status (1=healthy, 0=unhealthy)",
|
|
1098
|
-
unit: "1"
|
|
1099
|
-
});
|
|
1100
|
-
const subscriberHealthCallback = (observableResult) => {
|
|
1101
|
-
for (const [subscriberName, isHealthy] of this.subscriberHealthy) observableResult.observe(isHealthy ? 1 : 0, { subscriber: subscriberName });
|
|
1102
|
-
};
|
|
1103
|
-
subscriberHealth.addCallback(subscriberHealthCallback);
|
|
1104
|
-
this.observableCleanups.push(() => subscriberHealth.removeCallback(subscriberHealthCallback));
|
|
1105
|
-
this.metrics = {
|
|
1106
|
-
queueSize,
|
|
1107
|
-
oldestAge,
|
|
1108
|
-
delivered,
|
|
1109
|
-
failed,
|
|
1110
|
-
dropped,
|
|
1111
|
-
latency,
|
|
1112
|
-
subscriberHealth
|
|
1113
|
-
};
|
|
1427
|
+
addAlwaysSampleUsers(...userIds) {
|
|
1428
|
+
for (const userId of userIds) this.alwaysSampleUsers.add(userId);
|
|
1114
1429
|
}
|
|
1115
1430
|
/**
|
|
1116
|
-
*
|
|
1431
|
+
* Remove user IDs from always-sample list
|
|
1117
1432
|
*/
|
|
1118
|
-
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1433
|
+
removeAlwaysSampleUsers(...userIds) {
|
|
1434
|
+
for (const userId of userIds) this.alwaysSampleUsers.delete(userId);
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Simple hash function for consistent user sampling
|
|
1438
|
+
*/
|
|
1439
|
+
hashString(str) {
|
|
1440
|
+
return hashUnitInterval(str);
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
/**
|
|
1444
|
+
* Consistent sampler: every service reaches the same verdict for one trace.
|
|
1445
|
+
*
|
|
1446
|
+
* `RandomSampler` rolls the dice per process, so an upstream service can keep
|
|
1447
|
+
* a trace that its downstream drops, leaving a waterfall with holes in it.
|
|
1448
|
+
* Hashing a key that travels with the request removes the disagreement. Pass
|
|
1449
|
+
* the trace id, or any identifier every hop already shares.
|
|
1450
|
+
*
|
|
1451
|
+
* @example
|
|
1452
|
+
* ```typescript
|
|
1453
|
+
* new DeterministicSampler({
|
|
1454
|
+
* sampleRate: 0.1,
|
|
1455
|
+
* key: (context) => trace.getActiveSpan()?.spanContext().traceId,
|
|
1456
|
+
* })
|
|
1457
|
+
* ```
|
|
1458
|
+
*/
|
|
1459
|
+
var DeterministicSampler = class {
|
|
1460
|
+
rate;
|
|
1461
|
+
key;
|
|
1462
|
+
constructor(options) {
|
|
1463
|
+
if (options.sampleRate < 0 || options.sampleRate > 1) throw new Error("Sample rate must be between 0 and 1");
|
|
1464
|
+
this.rate = options.sampleRate;
|
|
1465
|
+
this.key = options.key;
|
|
1466
|
+
}
|
|
1467
|
+
shouldSample(context) {
|
|
1468
|
+
const key = this.key(context);
|
|
1469
|
+
if (key === void 0) return Math.random() < this.rate;
|
|
1470
|
+
return hashUnitInterval(key) < this.rate;
|
|
1471
|
+
}
|
|
1472
|
+
sampleRate() {
|
|
1473
|
+
return toSampleRate(this.rate);
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
/** Bucket for keys seen after the tracked map fills up. */
|
|
1477
|
+
const OVERFLOW_KEY = "__overflow__";
|
|
1478
|
+
/**
|
|
1479
|
+
* Per-key target-rate sampler for workloads with uneven traffic.
|
|
1480
|
+
*
|
|
1481
|
+
* A single rate serves a skewed workload badly: 1% floods storage with the
|
|
1482
|
+
* busiest endpoint and still loses the rare tenant whose failures you need.
|
|
1483
|
+
* This sampler counts traffic per key over a rolling window, then sets each
|
|
1484
|
+
* key its own rate so every key contributes roughly `targetPerKey` events.
|
|
1485
|
+
* Quiet keys survive intact; loud keys get thinned.
|
|
1486
|
+
*
|
|
1487
|
+
* The first window keeps everything, because no traffic history exists yet.
|
|
1488
|
+
* Rates take effect from the second window onward.
|
|
1489
|
+
*
|
|
1490
|
+
* @example
|
|
1491
|
+
* ```typescript
|
|
1492
|
+
* new KeyTargetRateSampler({
|
|
1493
|
+
* key: (context) => context.operationName,
|
|
1494
|
+
* targetPerKey: 10, // ~10 events per key per window
|
|
1495
|
+
* windowMs: 30_000,
|
|
1496
|
+
* })
|
|
1497
|
+
* ```
|
|
1498
|
+
*/
|
|
1499
|
+
var KeyTargetRateSampler = class {
|
|
1500
|
+
key;
|
|
1501
|
+
targetPerKey;
|
|
1502
|
+
windowMs;
|
|
1503
|
+
maxKeys;
|
|
1504
|
+
counts = /* @__PURE__ */ new Map();
|
|
1505
|
+
rates = /* @__PURE__ */ new Map();
|
|
1506
|
+
windowStart = Date.now();
|
|
1507
|
+
constructor(options) {
|
|
1508
|
+
this.key = options.key;
|
|
1509
|
+
this.targetPerKey = options.targetPerKey ?? 10;
|
|
1510
|
+
this.windowMs = options.windowMs ?? 3e4;
|
|
1511
|
+
this.maxKeys = options.maxKeys ?? 1e3;
|
|
1512
|
+
if (this.targetPerKey <= 0) throw new Error("Target per key must be greater than 0");
|
|
1513
|
+
if (this.windowMs <= 0) throw new Error("Window must be greater than 0");
|
|
1514
|
+
}
|
|
1515
|
+
/** Turn the window's observed counts into the next window's rates. */
|
|
1516
|
+
roll(now) {
|
|
1517
|
+
if (now - this.windowStart < this.windowMs) return;
|
|
1518
|
+
const rates = /* @__PURE__ */ new Map();
|
|
1519
|
+
for (const [key, count] of this.counts) rates.set(key, Math.max(1, count / this.targetPerKey));
|
|
1520
|
+
this.rates = rates;
|
|
1521
|
+
this.counts = /* @__PURE__ */ new Map();
|
|
1522
|
+
this.windowStart = now;
|
|
1138
1523
|
}
|
|
1139
1524
|
/**
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1525
|
+
* Resolve the key, collapsing into one bucket once the map is full.
|
|
1526
|
+
*
|
|
1527
|
+
* An unbounded key function would otherwise grow the map without limit,
|
|
1528
|
+
* which turns a sampler meant to cut cost into a memory leak.
|
|
1142
1529
|
*/
|
|
1143
|
-
|
|
1144
|
-
this.
|
|
1145
|
-
this.
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1530
|
+
resolveKey(context) {
|
|
1531
|
+
const key = this.key(context) ?? OVERFLOW_KEY;
|
|
1532
|
+
if (this.counts.has(key) || this.counts.size < this.maxKeys) return key;
|
|
1533
|
+
return OVERFLOW_KEY;
|
|
1534
|
+
}
|
|
1535
|
+
shouldSample(context) {
|
|
1536
|
+
this.roll(Date.now());
|
|
1537
|
+
const key = this.resolveKey(context);
|
|
1538
|
+
this.counts.set(key, (this.counts.get(key) ?? 0) + 1);
|
|
1539
|
+
const rate = this.rates.get(key) ?? 1;
|
|
1540
|
+
return rate <= 1 || Math.random() < 1 / rate;
|
|
1541
|
+
}
|
|
1542
|
+
sampleRate(context) {
|
|
1543
|
+
return this.rates.get(this.resolveKey(context)) ?? 1;
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
/**
|
|
1547
|
+
* Composite sampler that combines multiple samplers
|
|
1548
|
+
*
|
|
1549
|
+
* Samples if ANY of the child samplers returns true.
|
|
1550
|
+
*
|
|
1551
|
+
* @example
|
|
1552
|
+
* ```typescript
|
|
1553
|
+
* new CompositeSampler([
|
|
1554
|
+
* new UserIdSampler({ extractUserId: (args) => args[0]?.userId }),
|
|
1555
|
+
* new AdaptiveSampler({ baselineSampleRate: 0.1 })
|
|
1556
|
+
* ])
|
|
1557
|
+
* ```
|
|
1558
|
+
*/
|
|
1559
|
+
var CompositeSampler = class {
|
|
1560
|
+
samplers;
|
|
1561
|
+
constructor(samplers) {
|
|
1562
|
+
this.samplers = samplers;
|
|
1563
|
+
if (samplers.length === 0) throw new Error("CompositeSampler requires at least one child sampler");
|
|
1564
|
+
}
|
|
1565
|
+
shouldSample(context) {
|
|
1566
|
+
return this.samplers.some((sampler) => sampler.shouldSample(context));
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
/**
|
|
1570
|
+
* Feature flag sampler
|
|
1571
|
+
*
|
|
1572
|
+
* Always samples requests with specific feature flags enabled.
|
|
1573
|
+
* Perfect for correlating A/B test experiments with metrics.
|
|
1574
|
+
*
|
|
1575
|
+
* @example
|
|
1576
|
+
* ```typescript
|
|
1577
|
+
* new FeatureFlagSampler({
|
|
1578
|
+
* baselineSampleRate: 0.01,
|
|
1579
|
+
* alwaysSampleFlags: ['new_checkout', 'experimental_ui'],
|
|
1580
|
+
* extractFlags: (args, metadata) => metadata?.featureFlags
|
|
1581
|
+
* })
|
|
1582
|
+
* ```
|
|
1583
|
+
*/
|
|
1584
|
+
var FeatureFlagSampler = class {
|
|
1585
|
+
baselineSampleRate;
|
|
1586
|
+
alwaysSampleFlags;
|
|
1587
|
+
extractFlags;
|
|
1588
|
+
logger;
|
|
1589
|
+
constructor(options) {
|
|
1590
|
+
this.baselineSampleRate = options.baselineSampleRate ?? .1;
|
|
1591
|
+
this.alwaysSampleFlags = new Set(options.alwaysSampleFlags || []);
|
|
1592
|
+
this.extractFlags = options.extractFlags;
|
|
1593
|
+
this.logger = options.logger;
|
|
1594
|
+
}
|
|
1595
|
+
shouldSample(context) {
|
|
1596
|
+
const flags = this.extractFlags(context.args, context.metadata);
|
|
1597
|
+
if (flags && flags.some((flag) => this.alwaysSampleFlags.has(flag))) {
|
|
1598
|
+
this.logger?.debug({
|
|
1599
|
+
operation: context.operationName,
|
|
1600
|
+
flags
|
|
1601
|
+
}, "Sampling feature flag request");
|
|
1602
|
+
return true;
|
|
1603
|
+
}
|
|
1604
|
+
return Math.random() < this.baselineSampleRate;
|
|
1153
1605
|
}
|
|
1154
1606
|
/**
|
|
1155
|
-
*
|
|
1156
|
-
* Used during retry attempts - only recordFailed should increment the counter
|
|
1607
|
+
* Add feature flags to always-sample list
|
|
1157
1608
|
*/
|
|
1158
|
-
|
|
1159
|
-
this.
|
|
1609
|
+
addAlwaysSampleFlags(...flags) {
|
|
1610
|
+
for (const flag of flags) this.alwaysSampleFlags.add(flag);
|
|
1160
1611
|
}
|
|
1161
1612
|
/**
|
|
1162
|
-
*
|
|
1613
|
+
* Remove feature flags from always-sample list
|
|
1163
1614
|
*/
|
|
1164
|
-
|
|
1165
|
-
const
|
|
1166
|
-
this.metrics?.delivered.add(1, { subscriber: subscriberName });
|
|
1167
|
-
this.metrics?.latency.record(latencyMs, { subscriber: subscriberName });
|
|
1168
|
-
this.subscriberHealthy.set(subscriberName, true);
|
|
1615
|
+
removeAlwaysSampleFlags(...flags) {
|
|
1616
|
+
for (const flag of flags) this.alwaysSampleFlags.delete(flag);
|
|
1169
1617
|
}
|
|
1618
|
+
};
|
|
1619
|
+
/**
|
|
1620
|
+
* Sampling preset factories.
|
|
1621
|
+
*
|
|
1622
|
+
* For most users, the string shorthand on `init()` is simpler:
|
|
1623
|
+
* ```typescript
|
|
1624
|
+
* init({ service: 'my-app', sampling: 'production' })
|
|
1625
|
+
* ```
|
|
1626
|
+
*
|
|
1627
|
+
* Use factories when you need to customize:
|
|
1628
|
+
* ```typescript
|
|
1629
|
+
* init({ service: 'my-app', sampler: samplingPresets.production({ baselineSampleRate: 0.05 }) })
|
|
1630
|
+
* ```
|
|
1631
|
+
*/
|
|
1632
|
+
const samplingPresets = {
|
|
1633
|
+
/** Capture everything — best for local development and debugging */
|
|
1634
|
+
development: () => new AlwaysSampler(),
|
|
1635
|
+
/** Only bad outcomes — zero baseline, errors always kept */
|
|
1636
|
+
errorsOnly: () => new AdaptiveSampler({
|
|
1637
|
+
baselineSampleRate: 0,
|
|
1638
|
+
alwaysSampleErrors: true
|
|
1639
|
+
}),
|
|
1170
1640
|
/**
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1173
|
-
* Backpressure policy:
|
|
1174
|
-
* - Drops oldest event and logs warning if queue is full (same behavior in all environments)
|
|
1641
|
+
* Balanced production defaults — 10% baseline + errors + slow traces.
|
|
1642
|
+
* Pass overrides to tune (uses the same option names as AdaptiveSampler).
|
|
1175
1643
|
*/
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1644
|
+
production: (overrides) => new AdaptiveSampler({
|
|
1645
|
+
baselineSampleRate: .1,
|
|
1646
|
+
alwaysSampleErrors: true,
|
|
1647
|
+
alwaysSampleSlow: true,
|
|
1648
|
+
slowThresholdMs: 1e3,
|
|
1649
|
+
...overrides
|
|
1650
|
+
}),
|
|
1651
|
+
/** Disable sampling entirely */
|
|
1652
|
+
off: () => new NeverSampler()
|
|
1653
|
+
};
|
|
1654
|
+
/**
|
|
1655
|
+
* Resolve a preset string to a Sampler instance.
|
|
1656
|
+
* Used internally by `init()` when `sampling` string is provided.
|
|
1657
|
+
*
|
|
1658
|
+
* @throws Error if preset is not recognized
|
|
1659
|
+
*/
|
|
1660
|
+
function resolveSamplingPreset(preset) {
|
|
1661
|
+
switch (preset) {
|
|
1662
|
+
case "development": return samplingPresets.development();
|
|
1663
|
+
case "errors-only": return samplingPresets.errorsOnly();
|
|
1664
|
+
case "production": return samplingPresets.production();
|
|
1665
|
+
case "off": return samplingPresets.off();
|
|
1666
|
+
default: throw new Error(`Unknown sampling preset: "${preset}". Valid presets: development, errors-only, production, off`);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Create a Link from W3C trace context headers (e.g., from a message queue).
|
|
1671
|
+
*
|
|
1672
|
+
* This is useful for message consumers that need to link to the producer span.
|
|
1673
|
+
* The headers should contain at least a `traceparent` header in W3C format.
|
|
1674
|
+
*
|
|
1675
|
+
* @param headers - Dictionary containing traceparent/tracestate headers
|
|
1676
|
+
* @param attributes - Optional attributes for the link
|
|
1677
|
+
* @returns Link object if context could be extracted, null otherwise
|
|
1678
|
+
*
|
|
1679
|
+
* @example
|
|
1680
|
+
* ```typescript
|
|
1681
|
+
* // In a Kafka consumer
|
|
1682
|
+
* const headers = { traceparent: '00-abc123...-def456...-01' };
|
|
1683
|
+
* const link = createLinkFromHeaders(headers);
|
|
1684
|
+
* if (link) {
|
|
1685
|
+
* // Use with tracer.startActiveSpan options or ctx.addLink()
|
|
1686
|
+
* tracer.startActiveSpan('process.message', { links: [link] }, span => { ... });
|
|
1687
|
+
* }
|
|
1688
|
+
* ```
|
|
1689
|
+
*/
|
|
1690
|
+
function createLinkFromHeaders(headers, attributes) {
|
|
1691
|
+
const traceparent = headers.traceparent || headers["traceparent"];
|
|
1692
|
+
if (!traceparent) return null;
|
|
1693
|
+
const spanContext = parseTraceparent(traceparent);
|
|
1694
|
+
if (!spanContext || !isValidSpanContext(spanContext)) return null;
|
|
1695
|
+
return {
|
|
1696
|
+
context: spanContext,
|
|
1697
|
+
attributes: attributes ?? {}
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Extract Links from a batch of messages for fan-in scenarios.
|
|
1702
|
+
*
|
|
1703
|
+
* Useful for batch processing where multiple producer spans should be linked.
|
|
1704
|
+
* This enables tracing causality in event-driven architectures where a single
|
|
1705
|
+
* consumer processes messages from multiple producers.
|
|
1706
|
+
*
|
|
1707
|
+
* @param messages - List of message objects
|
|
1708
|
+
* @param headersKey - Key in each message containing trace headers (default: 'headers')
|
|
1709
|
+
* @returns List of Link objects for all valid trace contexts
|
|
1710
|
+
*
|
|
1711
|
+
* @example
|
|
1712
|
+
* ```typescript
|
|
1713
|
+
* // Processing a batch of SQS/Kafka messages
|
|
1714
|
+
* const messages = [
|
|
1715
|
+
* { body: '...', headers: { traceparent: '...' } },
|
|
1716
|
+
* { body: '...', headers: { traceparent: '...' } },
|
|
1717
|
+
* ];
|
|
1718
|
+
* const links = extractLinksFromBatch(messages);
|
|
1719
|
+
*
|
|
1720
|
+
* tracer.startActiveSpan('process.batch', { links }, span => {
|
|
1721
|
+
* for (const msg of messages) {
|
|
1722
|
+
* processMessage(msg);
|
|
1723
|
+
* }
|
|
1724
|
+
* });
|
|
1725
|
+
* ```
|
|
1726
|
+
*/
|
|
1727
|
+
function extractLinksFromBatch(messages, headersKey = "headers") {
|
|
1728
|
+
const links = [];
|
|
1729
|
+
for (const msg of messages) {
|
|
1730
|
+
const msgHeaders = asStringRecord(msg[headersKey]);
|
|
1731
|
+
if (msgHeaders) {
|
|
1732
|
+
const link = createLinkFromHeaders(msgHeaders, { "messaging.batch.message_index": links.length });
|
|
1733
|
+
if (link) links.push(link);
|
|
1180
1734
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1735
|
+
}
|
|
1736
|
+
return links;
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Parse W3C traceparent header into SpanContext
|
|
1740
|
+
* Format: version-traceId-spanId-traceFlags (e.g., 00-abc123...-def456...-01)
|
|
1741
|
+
*
|
|
1742
|
+
* @see https://www.w3.org/TR/trace-context/#traceparent-header
|
|
1743
|
+
*/
|
|
1744
|
+
function parseTraceparent(traceparent) {
|
|
1745
|
+
const match = traceparent.match(/^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i);
|
|
1746
|
+
if (!match || match.length < 5) return null;
|
|
1747
|
+
const version = match[1];
|
|
1748
|
+
const traceId = match[2];
|
|
1749
|
+
const spanId = match[3];
|
|
1750
|
+
const flags = match[4];
|
|
1751
|
+
if (!version || !traceId || !spanId || !flags) return null;
|
|
1752
|
+
if (version === "ff") return null;
|
|
1753
|
+
return {
|
|
1754
|
+
traceId,
|
|
1755
|
+
spanId,
|
|
1756
|
+
traceFlags: Number.parseInt(flags, 16),
|
|
1757
|
+
isRemote: true
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Check if a SpanContext is valid (has non-zero trace and span IDs)
|
|
1762
|
+
*/
|
|
1763
|
+
function isValidSpanContext(spanContext) {
|
|
1764
|
+
if (!spanContext) return false;
|
|
1765
|
+
return spanContext.traceId !== "00000000000000000000000000000000" && spanContext.spanId !== "0000000000000000";
|
|
1766
|
+
}
|
|
1767
|
+
/** True when the request in flight asked for full-fidelity capture. */
|
|
1768
|
+
function debugCaptureRequested() {
|
|
1769
|
+
const entry = propagation.getBaggage(getActiveContextWithBaggage())?.getEntry(AUTOTEL_DEBUG_BAGGAGE_KEY);
|
|
1770
|
+
return entry !== void 0 && entry.value !== "" && entry.value !== "0";
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
//#endregion
|
|
1774
|
+
//#region src/init-logger.ts
|
|
1775
|
+
const LOG_LEVELS = {
|
|
1776
|
+
debug: 0,
|
|
1777
|
+
info: 1,
|
|
1778
|
+
warn: 2,
|
|
1779
|
+
error: 3
|
|
1780
|
+
};
|
|
1781
|
+
/** Silent logger used until and unless the application opts into diagnostics. */
|
|
1782
|
+
const silentLogger = {
|
|
1783
|
+
info: () => {},
|
|
1784
|
+
warn: () => {},
|
|
1785
|
+
error: () => {},
|
|
1786
|
+
debug: () => {}
|
|
1787
|
+
};
|
|
1788
|
+
/** Apply the resolved silence and minimum-level settings to a logger. */
|
|
1789
|
+
function wrapLogger(base, silent, minLevel) {
|
|
1790
|
+
if (silent) return silentLogger;
|
|
1791
|
+
const threshold = LOG_LEVELS[minLevel];
|
|
1792
|
+
const wrap = (fn, level) => {
|
|
1793
|
+
if (LOG_LEVELS[level] < threshold) return (() => {});
|
|
1794
|
+
return ((...args) => fn(...args));
|
|
1795
|
+
};
|
|
1796
|
+
return {
|
|
1797
|
+
debug: wrap(base.debug, "debug"),
|
|
1798
|
+
info: wrap(base.info, "info"),
|
|
1799
|
+
warn: wrap(base.warn, "warn"),
|
|
1800
|
+
error: wrap(base.error, "error")
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
//#endregion
|
|
1805
|
+
//#region src/posthog-logs.ts
|
|
1806
|
+
var RedactingLogRecordProcessor = class {
|
|
1807
|
+
wrapped;
|
|
1808
|
+
redact;
|
|
1809
|
+
constructor(wrapped, redact) {
|
|
1810
|
+
this.wrapped = wrapped;
|
|
1811
|
+
this.redact = redact;
|
|
1812
|
+
}
|
|
1813
|
+
onEmit(logRecord, context) {
|
|
1814
|
+
const body = asString(logRecord.body);
|
|
1815
|
+
if (body) logRecord.body = this.redact(body);
|
|
1816
|
+
if (logRecord.attributes) for (const [key, value] of Object.entries(logRecord.attributes)) {
|
|
1817
|
+
const text = asString(value);
|
|
1818
|
+
if (text !== void 0) logRecord.attributes[key] = this.redact(text);
|
|
1819
|
+
else if (Array.isArray(value)) logRecord.attributes[key] = value.map((item) => {
|
|
1820
|
+
const entry = asString(item);
|
|
1821
|
+
return entry === void 0 ? item : this.redact(entry);
|
|
1822
|
+
});
|
|
1185
1823
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1824
|
+
this.wrapped.onEmit(logRecord, context);
|
|
1825
|
+
}
|
|
1826
|
+
shutdown() {
|
|
1827
|
+
return this.wrapped.shutdown();
|
|
1828
|
+
}
|
|
1829
|
+
forceFlush() {
|
|
1830
|
+
return this.wrapped.forceFlush();
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
/**
|
|
1834
|
+
* Build log record processors for PostHog OTLP logs integration.
|
|
1835
|
+
*
|
|
1836
|
+
* Resolution order:
|
|
1837
|
+
* 1. config.url if provided
|
|
1838
|
+
* 2. POSTHOG_LOGS_URL env var
|
|
1839
|
+
* 3. Empty array (disabled)
|
|
1840
|
+
*/
|
|
1841
|
+
function buildPostHogLogProcessors(config, stringRedactor) {
|
|
1842
|
+
const url = config?.url || process.env.POSTHOG_LOGS_URL;
|
|
1843
|
+
if (!url) return [];
|
|
1844
|
+
const sdkLogs = safeRequire("@opentelemetry/sdk-logs");
|
|
1845
|
+
const exporterModule = safeRequire("@opentelemetry/exporter-logs-otlp-http");
|
|
1846
|
+
if (!sdkLogs || !exporterModule) return [];
|
|
1847
|
+
const exporter = new exporterModule.OTLPLogExporter({ url });
|
|
1848
|
+
let processor = new sdkLogs.BatchLogRecordProcessor({ exporter });
|
|
1849
|
+
if (stringRedactor) processor = new RedactingLogRecordProcessor(processor, stringRedactor);
|
|
1850
|
+
return [processor];
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
//#endregion
|
|
1854
|
+
//#region src/tail-sampling-processor.ts
|
|
1855
|
+
var TailSamplingSpanProcessor = class {
|
|
1856
|
+
wrappedProcessor;
|
|
1857
|
+
constructor(wrappedProcessor) {
|
|
1858
|
+
this.wrappedProcessor = wrappedProcessor;
|
|
1859
|
+
}
|
|
1860
|
+
onStart(span, parentContext) {
|
|
1861
|
+
this.wrappedProcessor.onStart(span, parentContext);
|
|
1862
|
+
}
|
|
1863
|
+
onEnd(span) {
|
|
1864
|
+
const tailEvaluated = span.attributes[AUTOTEL_SAMPLING_TAIL_EVALUATED];
|
|
1865
|
+
const shouldKeep = span.attributes[AUTOTEL_SAMPLING_TAIL_KEEP];
|
|
1866
|
+
if (tailEvaluated === true && shouldKeep === false) return;
|
|
1867
|
+
this.wrappedProcessor.onEnd(span);
|
|
1868
|
+
}
|
|
1869
|
+
forceFlush() {
|
|
1870
|
+
return this.wrappedProcessor.forceFlush();
|
|
1871
|
+
}
|
|
1872
|
+
shutdown() {
|
|
1873
|
+
return this.wrappedProcessor.shutdown();
|
|
1874
|
+
}
|
|
1875
|
+
};
|
|
1876
|
+
|
|
1877
|
+
//#endregion
|
|
1878
|
+
//#region src/baggage-span-processor.ts
|
|
1879
|
+
/**
|
|
1880
|
+
* Span processor that automatically copies baggage entries to span attributes
|
|
1881
|
+
*
|
|
1882
|
+
* This makes baggage visible in trace UIs (Jaeger, Grafana, DataDog, etc.)
|
|
1883
|
+
* without manually calling ctx.setAttribute() for each baggage entry.
|
|
1884
|
+
*
|
|
1885
|
+
* @example Enable in init()
|
|
1886
|
+
* ```typescript
|
|
1887
|
+
* init({
|
|
1888
|
+
* service: 'my-app',
|
|
1889
|
+
* baggage: true // Uses default 'baggage.' prefix
|
|
1890
|
+
* });
|
|
1891
|
+
*
|
|
1892
|
+
* // Now baggage automatically appears as span attributes
|
|
1893
|
+
* await withBaggage({
|
|
1894
|
+
* baggage: { 'tenant.id': 't1', 'user.id': 'u1' },
|
|
1895
|
+
* fn: async () => {
|
|
1896
|
+
* // Span has baggage.tenant.id and baggage.user.id attributes!
|
|
1897
|
+
* }
|
|
1898
|
+
* });
|
|
1899
|
+
* ```
|
|
1900
|
+
*
|
|
1901
|
+
* @example Custom prefix
|
|
1902
|
+
* ```typescript
|
|
1903
|
+
* init({
|
|
1904
|
+
* service: 'my-app',
|
|
1905
|
+
* baggage: 'ctx' // Uses 'ctx.' prefix
|
|
1906
|
+
* });
|
|
1907
|
+
* // Creates attributes: ctx.tenant.id, ctx.user.id
|
|
1908
|
+
* ```
|
|
1909
|
+
*/
|
|
1910
|
+
var BaggageSpanProcessor = class {
|
|
1911
|
+
prefix;
|
|
1912
|
+
constructor(options = {}) {
|
|
1913
|
+
this.prefix = options.prefix ?? "baggage.";
|
|
1192
1914
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1915
|
+
onStart(span, parentContext) {
|
|
1916
|
+
let baggage = propagation.getBaggage(parentContext);
|
|
1917
|
+
if (!baggage) baggage = propagation.getBaggage(context.active());
|
|
1918
|
+
if (!baggage) try {
|
|
1919
|
+
const { getActiveContextWithBaggage } = requireModule("./trace-context");
|
|
1920
|
+
const storedContext = getActiveContextWithBaggage();
|
|
1921
|
+
baggage = propagation.getBaggage(storedContext);
|
|
1922
|
+
} catch {}
|
|
1923
|
+
if (!baggage) return;
|
|
1924
|
+
for (const [key, entry] of baggage.getAllEntries()) span.setAttribute(`${this.prefix}${key}`, entry.value);
|
|
1202
1925
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1926
|
+
onEnd(_span) {}
|
|
1927
|
+
async shutdown() {}
|
|
1928
|
+
async forceFlush() {}
|
|
1929
|
+
};
|
|
1930
|
+
|
|
1931
|
+
//#endregion
|
|
1932
|
+
//#region src/redact-values.ts
|
|
1933
|
+
/** Standalone string redaction for use outside the span processor pipeline. */
|
|
1934
|
+
function createStringRedactor(config) {
|
|
1935
|
+
const resolved = typeof config === "string" ? REDACTOR_PRESETS[config] : config;
|
|
1936
|
+
const valuePatterns = resolved.valuePatterns ?? [];
|
|
1937
|
+
const defaultReplacement = resolved.replacement ?? "[REDACTED]";
|
|
1938
|
+
return (value) => {
|
|
1939
|
+
let result = value;
|
|
1940
|
+
for (const { pattern, replacement, mask } of valuePatterns) {
|
|
1941
|
+
pattern.lastIndex = 0;
|
|
1942
|
+
result = mask ? result.replaceAll(pattern, (match) => mask(match)) : result.replaceAll(pattern, replacement ?? defaultReplacement);
|
|
1219
1943
|
}
|
|
1944
|
+
return result;
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
//#endregion
|
|
1949
|
+
//#region src/env-config.ts
|
|
1950
|
+
/**
|
|
1951
|
+
* Validate URL format
|
|
1952
|
+
*/
|
|
1953
|
+
function isValidUrl(urlString) {
|
|
1954
|
+
try {
|
|
1955
|
+
const url = new URL(urlString);
|
|
1956
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1957
|
+
} catch {
|
|
1958
|
+
return false;
|
|
1220
1959
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
* Tracks per-event, per-subscriber failures so failed counter reflects actual failed deliveries.
|
|
1231
|
-
* On retry, only failed (event, subscriber) pairs are re-sent to avoid double-counting delivered.
|
|
1232
|
-
*/
|
|
1233
|
-
async sendWithRetry(events, retriesLeft, subscribersByEventIndex) {
|
|
1234
|
-
const failedDeliveries = await this.sendToSubscribers(events, subscribersByEventIndex);
|
|
1235
|
-
if (failedDeliveries.length > 0) if (retriesLeft > 0) {
|
|
1236
|
-
const failedEventIndicesOrdered = [...new Set(failedDeliveries.map((f) => f.eventIndex))].toSorted((a, b) => a - b);
|
|
1237
|
-
const eventsToRetry = failedEventIndicesOrdered.map((i) => events[i]);
|
|
1238
|
-
const failedSubscribersByRetryIndex = /* @__PURE__ */ new Map();
|
|
1239
|
-
for (const [j, origIndex] of failedEventIndicesOrdered.entries()) {
|
|
1240
|
-
const set = /* @__PURE__ */ new Set();
|
|
1241
|
-
for (const { eventIndex, subscriberName } of failedDeliveries) if (eventIndex === origIndex) set.add(subscriberName);
|
|
1242
|
-
failedSubscribersByRetryIndex.set(j, set);
|
|
1243
|
-
}
|
|
1244
|
-
const delay = Math.pow(2, this.config.maxRetries - retriesLeft) * 1e3;
|
|
1245
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1246
|
-
return this.sendWithRetry(eventsToRetry, retriesLeft - 1, failedSubscribersByRetryIndex);
|
|
1247
|
-
} else {
|
|
1248
|
-
for (const { eventIndex, subscriberName, error } of failedDeliveries) {
|
|
1249
|
-
const event = events[eventIndex];
|
|
1250
|
-
if (event) this.recordFailed(event, subscriberName, error);
|
|
1251
|
-
}
|
|
1252
|
-
const failedSubscriberNames = [...new Set(failedDeliveries.map((f) => f.subscriberName))];
|
|
1253
|
-
getLogger().error({
|
|
1254
|
-
failedSubscribers: failedSubscriberNames,
|
|
1255
|
-
retriesAttempted: this.config.maxRetries
|
|
1256
|
-
}, "[autotel] Failed to send events after retries");
|
|
1257
|
-
}
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Resolve OpenTelemetry environment variables from process.env
|
|
1963
|
+
*/
|
|
1964
|
+
function resolveOtelEnv() {
|
|
1965
|
+
const env = {};
|
|
1966
|
+
if (process.env.OTEL_SERVICE_NAME) {
|
|
1967
|
+
const value = process.env.OTEL_SERVICE_NAME.trim();
|
|
1968
|
+
if (value) env.OTEL_SERVICE_NAME = value;
|
|
1258
1969
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
* Returns per-event, per-subscriber failures (empty if all succeeded).
|
|
1263
|
-
*/
|
|
1264
|
-
async sendToSubscribers(events, subscribersByEventIndex) {
|
|
1265
|
-
const failedDeliveries = [];
|
|
1266
|
-
const sendOne = async (event, eventIndex) => {
|
|
1267
|
-
const subscriberNames = subscribersByEventIndex?.get(eventIndex);
|
|
1268
|
-
const failures = await this.sendEventToSubscribers(event, subscriberNames ?? void 0);
|
|
1269
|
-
for (const failure of failures) failedDeliveries.push({
|
|
1270
|
-
eventIndex,
|
|
1271
|
-
subscriberName: failure.subscriberName,
|
|
1272
|
-
error: failure.error
|
|
1273
|
-
});
|
|
1274
|
-
};
|
|
1275
|
-
if (!this.rateLimiter) {
|
|
1276
|
-
for (const [i, event] of events.entries()) if (event) await sendOne(event, i);
|
|
1277
|
-
return failedDeliveries;
|
|
1278
|
-
}
|
|
1279
|
-
for (const [i, event] of events.entries()) {
|
|
1280
|
-
await this.rateLimiter.waitForToken();
|
|
1281
|
-
if (event) await sendOne(event, i);
|
|
1282
|
-
}
|
|
1283
|
-
return failedDeliveries;
|
|
1970
|
+
if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
1971
|
+
const value = process.env.OTEL_EXPORTER_OTLP_ENDPOINT.trim();
|
|
1972
|
+
if (value && isValidUrl(value)) env.OTEL_EXPORTER_OTLP_ENDPOINT = value;
|
|
1284
1973
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
* - When subscriberNames is provided (retry): send only to those subscribers (never re-send to healthy ones).
|
|
1289
|
-
* Returns list of subscribers that failed (empty if all succeeded).
|
|
1290
|
-
*/
|
|
1291
|
-
async sendEventToSubscribers(event, subscriberNames) {
|
|
1292
|
-
const startTime = event.timestamp;
|
|
1293
|
-
const failures = [];
|
|
1294
|
-
const subscribersToTry = subscriberNames === void 0 ? this.subscribers : this.subscribers.filter((s) => subscriberNames.has(getSubscriberName(s)));
|
|
1295
|
-
const results = await Promise.allSettled(subscribersToTry.map(async (subscriber) => {
|
|
1296
|
-
const subscriberName = getSubscriberName(subscriber);
|
|
1297
|
-
try {
|
|
1298
|
-
await subscriber.trackEvent(event.name, event.attributes, {
|
|
1299
|
-
autotel: event.autotel,
|
|
1300
|
-
schema: event.schema
|
|
1301
|
-
});
|
|
1302
|
-
this.recordDelivered(event, subscriberName, startTime);
|
|
1303
|
-
return {
|
|
1304
|
-
subscriberName,
|
|
1305
|
-
success: true
|
|
1306
|
-
};
|
|
1307
|
-
} catch (error) {
|
|
1308
|
-
this.markSubscriberUnhealthy(subscriberName);
|
|
1309
|
-
return {
|
|
1310
|
-
subscriberName,
|
|
1311
|
-
success: false,
|
|
1312
|
-
error: error instanceof Error ? error : void 0
|
|
1313
|
-
};
|
|
1314
|
-
}
|
|
1315
|
-
}));
|
|
1316
|
-
for (const result of results) if (result.status === "fulfilled" && !result.value.success) failures.push({
|
|
1317
|
-
subscriberName: result.value.subscriberName,
|
|
1318
|
-
error: result.value.error
|
|
1319
|
-
});
|
|
1320
|
-
return failures;
|
|
1974
|
+
if (process.env.OTEL_EXPORTER_OTLP_HEADERS) {
|
|
1975
|
+
const value = process.env.OTEL_EXPORTER_OTLP_HEADERS.trim();
|
|
1976
|
+
if (value) env.OTEL_EXPORTER_OTLP_HEADERS = value;
|
|
1321
1977
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
*/
|
|
1326
|
-
async flush() {
|
|
1327
|
-
if (this.flushTimer) {
|
|
1328
|
-
clearTimeout(this.flushTimer);
|
|
1329
|
-
this.flushTimer = null;
|
|
1330
|
-
}
|
|
1331
|
-
if (this.flushPromise) await this.flushPromise;
|
|
1332
|
-
while (this.queue.length > 0) await this.doFlushBatch();
|
|
1978
|
+
if (process.env.OTEL_RESOURCE_ATTRIBUTES) {
|
|
1979
|
+
const value = process.env.OTEL_RESOURCE_ATTRIBUTES.trim();
|
|
1980
|
+
if (value) env.OTEL_RESOURCE_ATTRIBUTES = value;
|
|
1333
1981
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
async shutdown() {
|
|
1339
|
-
this.isShuttingDown = true;
|
|
1340
|
-
await this.flush();
|
|
1341
|
-
const drains = await Promise.allSettled(this.subscribers.map(async (subscriber) => {
|
|
1342
|
-
if (!subscriber.shutdown) return;
|
|
1343
|
-
shutDownSubscribers.add(subscriber);
|
|
1344
|
-
return subscriber.shutdown();
|
|
1345
|
-
}));
|
|
1346
|
-
for (const [index, drain] of drains.entries()) {
|
|
1347
|
-
if (drain.status !== "rejected") continue;
|
|
1348
|
-
const subscriberName = getSubscriberName(this.subscribers[index]);
|
|
1349
|
-
this.subscriberHealthy.set(subscriberName, false);
|
|
1350
|
-
getLogger().error({
|
|
1351
|
-
subscriber: subscriberName,
|
|
1352
|
-
err: drain.reason
|
|
1353
|
-
}, "[autotel] Subscriber shutdown failed, buffered events may be lost");
|
|
1354
|
-
}
|
|
1982
|
+
if (process.env.OTEL_EXPORTER_OTLP_PROTOCOL) {
|
|
1983
|
+
const value = process.env.OTEL_EXPORTER_OTLP_PROTOCOL.trim().toLowerCase();
|
|
1984
|
+
if (value === "http" || value === "http/json") env.OTEL_EXPORTER_OTLP_PROTOCOL = "http";
|
|
1985
|
+
else if (value === "http/protobuf" || value === "grpc") env.OTEL_EXPORTER_OTLP_PROTOCOL = value;
|
|
1355
1986
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
*/
|
|
1360
|
-
cleanup() {
|
|
1361
|
-
for (const cleanupFn of this.observableCleanups) try {
|
|
1362
|
-
cleanupFn();
|
|
1363
|
-
} catch {}
|
|
1364
|
-
this.observableCleanups = [];
|
|
1987
|
+
if (process.env.OTEL_TRACES_SAMPLER) {
|
|
1988
|
+
const value = process.env.OTEL_TRACES_SAMPLER.trim();
|
|
1989
|
+
if (value) env.OTEL_TRACES_SAMPLER = value;
|
|
1365
1990
|
}
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
size() {
|
|
1370
|
-
return this.queue.length;
|
|
1991
|
+
if (process.env.OTEL_TRACES_SAMPLER_ARG) {
|
|
1992
|
+
const value = process.env.OTEL_TRACES_SAMPLER_ARG.trim();
|
|
1993
|
+
if (value) env.OTEL_TRACES_SAMPLER_ARG = value;
|
|
1371
1994
|
}
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1995
|
+
return env;
|
|
1996
|
+
}
|
|
1997
|
+
function parseRatioSamplerArg(samplerName, samplerArg) {
|
|
1998
|
+
if (samplerArg === void 0) return 1;
|
|
1999
|
+
const ratio = Number(samplerArg);
|
|
2000
|
+
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
|
|
2001
|
+
console.error(`[autotel] Invalid OTEL_TRACES_SAMPLER_ARG="${samplerArg}" for ${samplerName}. Expected a number in [0..1]. Falling back to 1.0.`);
|
|
2002
|
+
return 1;
|
|
1377
2003
|
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
2004
|
+
return ratio;
|
|
2005
|
+
}
|
|
2006
|
+
function warnOnUnusedSamplerArg(samplerName, samplerArg) {
|
|
2007
|
+
if (samplerArg !== void 0) console.error(`[autotel] OTEL_TRACES_SAMPLER_ARG is not used by OTEL_TRACES_SAMPLER="${samplerName}". Ignoring value "${samplerArg}".`);
|
|
2008
|
+
}
|
|
2009
|
+
function createSamplerFromEnv(env) {
|
|
2010
|
+
const samplerName = env.OTEL_TRACES_SAMPLER;
|
|
2011
|
+
if (!samplerName) return;
|
|
2012
|
+
switch (samplerName) {
|
|
2013
|
+
case "always_on":
|
|
2014
|
+
warnOnUnusedSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG);
|
|
2015
|
+
return new AlwaysOnSampler();
|
|
2016
|
+
case "always_off":
|
|
2017
|
+
warnOnUnusedSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG);
|
|
2018
|
+
return new AlwaysOffSampler();
|
|
2019
|
+
case "traceidratio": return new TraceIdRatioBasedSampler(parseRatioSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG));
|
|
2020
|
+
case "parentbased_always_on":
|
|
2021
|
+
warnOnUnusedSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG);
|
|
2022
|
+
return new ParentBasedSampler({ root: new AlwaysOnSampler() });
|
|
2023
|
+
case "parentbased_always_off":
|
|
2024
|
+
warnOnUnusedSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG);
|
|
2025
|
+
return new ParentBasedSampler({ root: new AlwaysOffSampler() });
|
|
2026
|
+
case "parentbased_traceidratio": return new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(parseRatioSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG)) });
|
|
2027
|
+
case "jaeger_remote":
|
|
2028
|
+
case "parentbased_jaeger_remote":
|
|
2029
|
+
case "xray":
|
|
2030
|
+
console.error(`[autotel] OTEL_TRACES_SAMPLER="${samplerName}" is not supported yet by autotel. Falling back to the next sampler source.`);
|
|
2031
|
+
return;
|
|
2032
|
+
default:
|
|
2033
|
+
console.error(`[autotel] Unknown OTEL_TRACES_SAMPLER="${samplerName}". Falling back to the next sampler source.`);
|
|
2034
|
+
return;
|
|
1383
2035
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Parse OTEL_RESOURCE_ATTRIBUTES from comma-separated key=value pairs
|
|
2039
|
+
* Example: "service.version=1.0.0,deployment.environment=production"
|
|
2040
|
+
*/
|
|
2041
|
+
function parseResourceAttributes(input) {
|
|
2042
|
+
if (!input || input.trim() === "") return {};
|
|
2043
|
+
const attributes = {};
|
|
2044
|
+
const pairs = input.split(",");
|
|
2045
|
+
for (const pair of pairs) {
|
|
2046
|
+
const trimmedPair = pair.trim();
|
|
2047
|
+
if (!trimmedPair) continue;
|
|
2048
|
+
const equalIndex = trimmedPair.indexOf("=");
|
|
2049
|
+
if (equalIndex === -1) continue;
|
|
2050
|
+
const key = trimmedPair.slice(0, equalIndex).trim();
|
|
2051
|
+
const value = trimmedPair.slice(equalIndex + 1).trim();
|
|
2052
|
+
if (key && value) attributes[key] = value;
|
|
1390
2053
|
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
/credential/i,
|
|
1410
|
-
/ssn/i,
|
|
1411
|
-
/credit[_-]?card/i
|
|
1412
|
-
]
|
|
1413
|
-
};
|
|
1414
|
-
var ValidationError = class extends Error {
|
|
1415
|
-
constructor(message) {
|
|
1416
|
-
super(message);
|
|
1417
|
-
this.name = "ValidationError";
|
|
2054
|
+
return attributes;
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Parse OTEL_EXPORTER_OTLP_HEADERS from comma-separated key=value pairs
|
|
2058
|
+
* Example: "api-key=secret123,x-custom-header=value"
|
|
2059
|
+
*/
|
|
2060
|
+
function parseOtlpHeaders(input) {
|
|
2061
|
+
if (!input || input.trim() === "") return {};
|
|
2062
|
+
const headers = {};
|
|
2063
|
+
const pairs = input.split(",");
|
|
2064
|
+
for (const pair of pairs) {
|
|
2065
|
+
const trimmedPair = pair.trim();
|
|
2066
|
+
if (!trimmedPair) continue;
|
|
2067
|
+
const equalIndex = trimmedPair.indexOf("=");
|
|
2068
|
+
if (equalIndex === -1) continue;
|
|
2069
|
+
const key = trimmedPair.slice(0, equalIndex).trim();
|
|
2070
|
+
const value = trimmedPair.slice(equalIndex + 1).trim();
|
|
2071
|
+
if (key && value) headers[key] = value;
|
|
1418
2072
|
}
|
|
1419
|
-
|
|
2073
|
+
return headers;
|
|
2074
|
+
}
|
|
1420
2075
|
/**
|
|
1421
|
-
*
|
|
1422
|
-
* Throws ValidationError if invalid
|
|
2076
|
+
* Convert resolved environment variables to config
|
|
1423
2077
|
*/
|
|
1424
|
-
function
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
if (
|
|
1428
|
-
if (
|
|
1429
|
-
if (
|
|
1430
|
-
|
|
2078
|
+
function envToConfig(env) {
|
|
2079
|
+
const config = {};
|
|
2080
|
+
if (env.OTEL_SERVICE_NAME) config.service = env.OTEL_SERVICE_NAME;
|
|
2081
|
+
if (env.OTEL_EXPORTER_OTLP_ENDPOINT) config.endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
2082
|
+
if (env.OTEL_EXPORTER_OTLP_PROTOCOL) config.protocol = env.OTEL_EXPORTER_OTLP_PROTOCOL;
|
|
2083
|
+
if (env.OTEL_EXPORTER_OTLP_HEADERS) config.headers = parseOtlpHeaders(env.OTEL_EXPORTER_OTLP_HEADERS);
|
|
2084
|
+
const resourceAttrs = parseResourceAttributes(env.OTEL_RESOURCE_ATTRIBUTES);
|
|
2085
|
+
if (Object.keys(resourceAttrs).length > 0) config.resourceAttributes = resourceAttrs;
|
|
2086
|
+
const sampler = createSamplerFromEnv(env);
|
|
2087
|
+
if (sampler) config.otelSampler = sampler;
|
|
2088
|
+
return config;
|
|
1431
2089
|
}
|
|
1432
2090
|
/**
|
|
1433
|
-
*
|
|
1434
|
-
* Returns sanitized attributes (sensitive data redacted)
|
|
2091
|
+
* Main function to resolve config from environment variables
|
|
1435
2092
|
*/
|
|
1436
|
-
function
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
2093
|
+
function resolveConfigFromEnv() {
|
|
2094
|
+
return envToConfig(resolveOtelEnv());
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
//#endregion
|
|
2098
|
+
//#region src/yaml-config.ts
|
|
2099
|
+
/**
|
|
2100
|
+
* YAML configuration loader for autotel
|
|
2101
|
+
*
|
|
2102
|
+
* Supports:
|
|
2103
|
+
* - Auto-discovery of autotel.yaml in cwd
|
|
2104
|
+
* - AUTOTEL_CONFIG_FILE env var override
|
|
2105
|
+
* - Environment variable substitution: ${env:VAR} and ${env:VAR:-default}
|
|
2106
|
+
*
|
|
2107
|
+
* @example Auto-discovery
|
|
2108
|
+
* ```yaml
|
|
2109
|
+
* # autotel.yaml in project root
|
|
2110
|
+
* service:
|
|
2111
|
+
* name: my-service
|
|
2112
|
+
* exporter:
|
|
2113
|
+
* endpoint: ${env:OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4318}
|
|
2114
|
+
* ```
|
|
2115
|
+
*
|
|
2116
|
+
* @example Explicit path
|
|
2117
|
+
* ```bash
|
|
2118
|
+
* AUTOTEL_CONFIG_FILE=./config/otel.yaml tsx --import autotel/auto src/index.ts
|
|
2119
|
+
* ```
|
|
2120
|
+
*/
|
|
2121
|
+
/**
|
|
2122
|
+
* Lazy-load yaml parser (optional peer dependency)
|
|
2123
|
+
* Only loads when a YAML config file is actually found
|
|
2124
|
+
*/
|
|
2125
|
+
function loadYamlParser() {
|
|
2126
|
+
try {
|
|
2127
|
+
return requireModule("yaml").parse;
|
|
2128
|
+
} catch {
|
|
2129
|
+
throw new Error("YAML parser not found. Install with: pnpm add yaml");
|
|
1450
2130
|
}
|
|
1451
|
-
return sanitized;
|
|
1452
2131
|
}
|
|
1453
2132
|
/**
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1456
|
-
* literal '[REDACTED]' leaks no signal while breaking every consumer that
|
|
1457
|
-
* treats them as a number.
|
|
2133
|
+
* Environment variable substitution regex
|
|
2134
|
+
* Matches ${env:VAR_NAME} and ${env:VAR_NAME:-default}
|
|
1458
2135
|
*/
|
|
1459
|
-
|
|
1460
|
-
|
|
2136
|
+
const ENV_VAR_PATTERN = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
2137
|
+
/**
|
|
2138
|
+
* Substitute ${env:VAR} and ${env:VAR:-default} in a string
|
|
2139
|
+
*
|
|
2140
|
+
* @param value - String potentially containing env var references
|
|
2141
|
+
* @returns String with env vars substituted
|
|
2142
|
+
*
|
|
2143
|
+
* @example
|
|
2144
|
+
* substituteEnvVars('${env:NODE_ENV:-development}')
|
|
2145
|
+
* // Returns 'production' if NODE_ENV=production, else 'development'
|
|
2146
|
+
*/
|
|
2147
|
+
function substituteEnvVars(value) {
|
|
2148
|
+
return value.replaceAll(ENV_VAR_PATTERN, (_match, varName, defaultValue) => {
|
|
2149
|
+
const envValue = process.env[varName];
|
|
2150
|
+
if (envValue !== void 0) return envValue;
|
|
2151
|
+
if (defaultValue !== void 0) return defaultValue;
|
|
2152
|
+
console.warn(`[autotel] Environment variable ${varName} not set and no default provided`);
|
|
2153
|
+
return "";
|
|
2154
|
+
});
|
|
1461
2155
|
}
|
|
1462
2156
|
/**
|
|
1463
|
-
*
|
|
2157
|
+
* Recursively substitute env vars in an object
|
|
2158
|
+
*
|
|
2159
|
+
* @param obj - Object to process
|
|
2160
|
+
* @returns Object with all string values having env vars substituted
|
|
1464
2161
|
*/
|
|
1465
|
-
function
|
|
1466
|
-
if (
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, config, depth + 1));
|
|
1473
|
-
const record = asRecord(value);
|
|
1474
|
-
if (record) try {
|
|
1475
|
-
JSON.stringify(record);
|
|
1476
|
-
const sanitized = {};
|
|
1477
|
-
for (const [key, nested] of Object.entries(record)) sanitized[key] = isSensitiveString(key, nested, config) ? "[REDACTED]" : sanitizeValue(nested, config, depth + 1);
|
|
1478
|
-
return sanitized;
|
|
1479
|
-
} catch {
|
|
1480
|
-
return "[CIRCULAR]";
|
|
2162
|
+
function substituteEnvVarsDeep(value) {
|
|
2163
|
+
if (Array.isArray(value)) return value.map(substituteEnvVarsDeep);
|
|
2164
|
+
const mapping = asYamlMapping(value);
|
|
2165
|
+
if (mapping) {
|
|
2166
|
+
const result = {};
|
|
2167
|
+
for (const [key, entry] of Object.entries(mapping)) result[key] = substituteEnvVarsDeep(entry);
|
|
2168
|
+
return result;
|
|
1481
2169
|
}
|
|
1482
|
-
|
|
2170
|
+
const text = asString(value);
|
|
2171
|
+
return text === void 0 ? value : substituteEnvVars(text);
|
|
2172
|
+
}
|
|
2173
|
+
/** The mapping a YAML value is, when it is one. */
|
|
2174
|
+
function asYamlMapping(value) {
|
|
2175
|
+
return asRecord(value) === void 0 ? void 0 : value;
|
|
1483
2176
|
}
|
|
1484
2177
|
/**
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
2178
|
+
* A YAML document read into the config shape, with `${env:...}` placeholders
|
|
2179
|
+
* resolved.
|
|
2180
|
+
*
|
|
2181
|
+
* This is the boundary: the file is text until here. Every field of
|
|
2182
|
+
* YamlConfig is optional, so any document satisfies it, and
|
|
2183
|
+
* `yamlToAutotelConfig` picks out only the fields it understands.
|
|
1487
2184
|
*/
|
|
1488
|
-
function
|
|
1489
|
-
|
|
1490
|
-
...DEFAULT_CONFIG$1,
|
|
1491
|
-
...config
|
|
1492
|
-
};
|
|
1493
|
-
return {
|
|
1494
|
-
eventName: validateEventName(eventName, fullConfig),
|
|
1495
|
-
attributes: validateAttributes(attributes, fullConfig)
|
|
1496
|
-
};
|
|
2185
|
+
function parseYamlConfig(content) {
|
|
2186
|
+
return asYamlMapping(substituteEnvVarsDeep(loadYamlParser()(content))) ?? {};
|
|
1497
2187
|
}
|
|
1498
|
-
|
|
1499
|
-
//#endregion
|
|
1500
|
-
//#region src/track.ts
|
|
1501
2188
|
/**
|
|
1502
|
-
*
|
|
2189
|
+
* Find YAML config file path
|
|
1503
2190
|
*
|
|
1504
|
-
*
|
|
2191
|
+
* Priority:
|
|
2192
|
+
* 1. AUTOTEL_CONFIG_FILE env var (explicit path)
|
|
2193
|
+
* 2. autotel.yaml in cwd (convention)
|
|
2194
|
+
* 3. autotel.yml in cwd (alternative extension)
|
|
2195
|
+
*
|
|
2196
|
+
* @returns File path if found, null otherwise
|
|
1505
2197
|
*/
|
|
1506
|
-
|
|
2198
|
+
function findConfigFile() {
|
|
2199
|
+
const envPath = process.env.AUTOTEL_CONFIG_FILE;
|
|
2200
|
+
if (envPath) {
|
|
2201
|
+
const resolved = path.resolve(envPath);
|
|
2202
|
+
if (nodeFs.existsSync(resolved)) return resolved;
|
|
2203
|
+
console.warn(`[autotel] Config file not found: ${envPath}`);
|
|
2204
|
+
return null;
|
|
2205
|
+
}
|
|
2206
|
+
const conventionPath = path.resolve(process.cwd(), "autotel.yaml");
|
|
2207
|
+
if (nodeFs.existsSync(conventionPath)) return conventionPath;
|
|
2208
|
+
const altPath = path.resolve(process.cwd(), "autotel.yml");
|
|
2209
|
+
if (nodeFs.existsSync(altPath)) return altPath;
|
|
2210
|
+
return null;
|
|
2211
|
+
}
|
|
1507
2212
|
/**
|
|
1508
|
-
*
|
|
2213
|
+
* Convert YAML config structure to AutotelConfig
|
|
1509
2214
|
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
* 2. Outside span → use correlation_id only
|
|
1513
|
-
* 3. With trace URL config → include clickable trace URL
|
|
2215
|
+
* @param yaml - Parsed and env-substituted YAML config
|
|
2216
|
+
* @returns Partial AutotelConfig ready for merging
|
|
1514
2217
|
*/
|
|
1515
|
-
function
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
if (
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
if (
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
if (
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
2218
|
+
function yamlToAutotelConfig(yaml) {
|
|
2219
|
+
const config = {};
|
|
2220
|
+
if (yaml.service?.name) config.service = yaml.service.name;
|
|
2221
|
+
if (yaml.service?.version) config.version = yaml.service.version;
|
|
2222
|
+
if (yaml.service?.environment) config.environment = yaml.service.environment;
|
|
2223
|
+
if (yaml.exporter?.endpoint) config.endpoint = yaml.exporter.endpoint;
|
|
2224
|
+
if (yaml.exporter?.protocol) config.protocol = yaml.exporter.protocol;
|
|
2225
|
+
if (yaml.exporter?.headers) config.headers = yaml.exporter.headers;
|
|
2226
|
+
if (yaml.exporter?.destinations) config.destinations = yaml.exporter.destinations;
|
|
2227
|
+
if (yaml.resource) config.resourceAttributes = yaml.resource;
|
|
2228
|
+
if (yaml.autoInstrumentations) config.autoInstrumentations = yaml.autoInstrumentations;
|
|
2229
|
+
if (yaml.policies) config.policies = yaml.policies;
|
|
2230
|
+
if (yaml.debug !== void 0) config.debug = yaml.debug;
|
|
2231
|
+
if (yaml.sampling?.preset) {
|
|
2232
|
+
warnOnIgnoredPresetOverrides(yaml.sampling);
|
|
2233
|
+
config.sampling = yaml.sampling.preset;
|
|
2234
|
+
} else {
|
|
2235
|
+
const sampler = createSamplerFromYaml(yaml.sampling);
|
|
2236
|
+
if (sampler) config.sampler = sampler;
|
|
2237
|
+
}
|
|
2238
|
+
return config;
|
|
2239
|
+
}
|
|
2240
|
+
function createSamplerFromYaml(sampling) {
|
|
2241
|
+
if (!sampling) return void 0;
|
|
2242
|
+
if (sampling.preset) return void 0;
|
|
2243
|
+
const type = sampling.type ?? "adaptive";
|
|
2244
|
+
try {
|
|
2245
|
+
switch (type) {
|
|
2246
|
+
case "adaptive": return new AdaptiveSampler({
|
|
2247
|
+
baselineSampleRate: sampling.baseline_rate,
|
|
2248
|
+
alwaysSampleErrors: sampling.always_sample_errors,
|
|
2249
|
+
alwaysSampleSlow: sampling.always_sample_slow,
|
|
2250
|
+
slowThresholdMs: sampling.slow_threshold_ms
|
|
1540
2251
|
});
|
|
1541
|
-
|
|
2252
|
+
case "always_on": return new AlwaysSampler();
|
|
2253
|
+
case "always_off": return new NeverSampler();
|
|
2254
|
+
case "ratio":
|
|
2255
|
+
if (sampling.ratio === void 0) {
|
|
2256
|
+
console.warn("[autotel] sampling.ratio missing in YAML sampling config. Falling back to adaptive sampler.");
|
|
2257
|
+
return new AdaptiveSampler();
|
|
2258
|
+
}
|
|
2259
|
+
return new RandomSampler(sampling.ratio);
|
|
2260
|
+
default:
|
|
2261
|
+
console.warn(`[autotel] Unknown sampling type "${type}" in YAML config. Falling back to defaults.`);
|
|
2262
|
+
return;
|
|
1542
2263
|
}
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
serviceName: config.service,
|
|
1547
|
-
environment: config.environment
|
|
1548
|
-
});
|
|
1549
|
-
if (traceUrl) autotelContext.trace_url = traceUrl;
|
|
2264
|
+
} catch (error) {
|
|
2265
|
+
console.warn(`[autotel] Failed to configure sampling from YAML: ${error instanceof Error ? error.message : String(error)}`);
|
|
2266
|
+
return;
|
|
1550
2267
|
}
|
|
1551
|
-
|
|
2268
|
+
}
|
|
2269
|
+
function warnOnIgnoredPresetOverrides(sampling) {
|
|
2270
|
+
const ignoredFields = [
|
|
2271
|
+
"type",
|
|
2272
|
+
"ratio",
|
|
2273
|
+
"baseline_rate",
|
|
2274
|
+
"always_sample_errors",
|
|
2275
|
+
"always_sample_slow",
|
|
2276
|
+
"slow_threshold_ms"
|
|
2277
|
+
].filter((field) => sampling[field] !== void 0);
|
|
2278
|
+
if (ignoredFields.length === 0) return;
|
|
2279
|
+
console.warn(`[autotel] sampling.preset="${sampling.preset}" ignores these YAML fields: ${ignoredFields.join(", ")}. Use the programmatic API with sampler or samplingPresets.*(...) for tuned presets.`);
|
|
1552
2280
|
}
|
|
1553
2281
|
/**
|
|
1554
|
-
*
|
|
2282
|
+
* Load and parse YAML config file (auto-discovery)
|
|
2283
|
+
*
|
|
2284
|
+
* Automatically finds and loads autotel.yaml or uses AUTOTEL_CONFIG_FILE.
|
|
2285
|
+
* Returns null if no config file found (not an error - YAML config is optional).
|
|
2286
|
+
*
|
|
2287
|
+
* @returns Partial AutotelConfig or null if no config file found
|
|
2288
|
+
*
|
|
2289
|
+
* @example
|
|
2290
|
+
* const yamlConfig = loadYamlConfig();
|
|
2291
|
+
* if (yamlConfig) {
|
|
2292
|
+
* init({ ...yamlConfig, debug: true });
|
|
2293
|
+
* }
|
|
1555
2294
|
*/
|
|
1556
|
-
function
|
|
1557
|
-
|
|
1558
|
-
|
|
2295
|
+
function loadYamlConfig() {
|
|
2296
|
+
const filePath = findConfigFile();
|
|
2297
|
+
if (!filePath) return null;
|
|
2298
|
+
try {
|
|
2299
|
+
return yamlToAutotelConfig(parseYamlConfig(nodeFs.readFileSync(filePath, "utf8")));
|
|
2300
|
+
} catch (error) {
|
|
2301
|
+
console.error(`[autotel] Failed to load YAML config from ${filePath}:`, error);
|
|
1559
2302
|
return null;
|
|
1560
2303
|
}
|
|
1561
|
-
if (!eventsQueue) {
|
|
1562
|
-
const config = getConfig();
|
|
1563
|
-
if (!config?.subscribers || config.subscribers.length === 0) return null;
|
|
1564
|
-
eventsQueue = new EventQueue(config.subscribers);
|
|
1565
|
-
}
|
|
1566
|
-
return eventsQueue;
|
|
1567
2304
|
}
|
|
1568
2305
|
/**
|
|
1569
|
-
*
|
|
2306
|
+
* Load YAML config from a specific file path
|
|
1570
2307
|
*
|
|
1571
|
-
*
|
|
1572
|
-
* - Auto-attaches traceId and spanId if in active span
|
|
1573
|
-
* - Batched sending with retry
|
|
1574
|
-
* - Type-safe with optional generic
|
|
1575
|
-
* - No-op if init() not called or no subscribers configured
|
|
2308
|
+
* Unlike loadYamlConfig(), this throws if the file cannot be read.
|
|
1576
2309
|
*
|
|
1577
|
-
* @
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
* ```
|
|
2310
|
+
* @param filePath - Path to YAML config file
|
|
2311
|
+
* @returns Partial AutotelConfig
|
|
2312
|
+
* @throws Error if file cannot be read or parsed
|
|
1581
2313
|
*
|
|
1582
|
-
* @example
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
* 'user.signup': { userId: string; plan: string }
|
|
1586
|
-
* 'plan.upgraded': { userId: string; revenue: number }
|
|
1587
|
-
* }
|
|
2314
|
+
* @example
|
|
2315
|
+
* import { loadYamlConfigFromFile } from 'autotel/yaml';
|
|
2316
|
+
* import { init } from 'autotel';
|
|
1588
2317
|
*
|
|
1589
|
-
*
|
|
1590
|
-
*
|
|
2318
|
+
* const config = loadYamlConfigFromFile('./config/otel.yaml');
|
|
2319
|
+
* init({ ...config, debug: true });
|
|
2320
|
+
*/
|
|
2321
|
+
function loadYamlConfigFromFile(filePath) {
|
|
2322
|
+
const resolved = path.resolve(filePath);
|
|
2323
|
+
return yamlToAutotelConfig(parseYamlConfig(nodeFs.readFileSync(resolved, "utf8")));
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Check if a YAML config file exists (without loading it)
|
|
1591
2327
|
*
|
|
1592
|
-
* @
|
|
1593
|
-
* ```typescript
|
|
1594
|
-
* @Instrumented()
|
|
1595
|
-
* class UserService {
|
|
1596
|
-
* async createUser(data: CreateUserData) {
|
|
1597
|
-
* // This track call automatically includes traceId + spanId
|
|
1598
|
-
* track('user.signup', { userId: data.id })
|
|
1599
|
-
* }
|
|
1600
|
-
* }
|
|
1601
|
-
* ```
|
|
2328
|
+
* @returns true if a config file would be found by loadYamlConfig()
|
|
1602
2329
|
*/
|
|
1603
|
-
function
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
2330
|
+
function hasYamlConfig() {
|
|
2331
|
+
return findConfigFile() !== null;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
//#endregion
|
|
2335
|
+
//#region src/devtools.ts
|
|
2336
|
+
const defaultHost = "127.0.0.1";
|
|
2337
|
+
const defaultPort = 4318;
|
|
2338
|
+
function resolveDevtoolsConfig(config) {
|
|
2339
|
+
if (!config) return {
|
|
2340
|
+
enabled: false,
|
|
2341
|
+
endpoint: void 0,
|
|
2342
|
+
embedded: false,
|
|
2343
|
+
host: defaultHost,
|
|
2344
|
+
port: defaultPort,
|
|
2345
|
+
verbose: false
|
|
2346
|
+
};
|
|
2347
|
+
if (config === true) return {
|
|
2348
|
+
enabled: true,
|
|
2349
|
+
endpoint: `http://${defaultHost}:${defaultPort}`,
|
|
2350
|
+
embedded: false,
|
|
2351
|
+
host: defaultHost,
|
|
2352
|
+
port: defaultPort,
|
|
2353
|
+
verbose: false
|
|
2354
|
+
};
|
|
2355
|
+
const enabled = config.enabled ?? true;
|
|
2356
|
+
const host = config.host ?? defaultHost;
|
|
2357
|
+
const port = config.port ?? defaultPort;
|
|
2358
|
+
const endpoint = config.endpoint ?? `http://${host}:${port}`;
|
|
2359
|
+
return {
|
|
2360
|
+
enabled,
|
|
2361
|
+
endpoint: enabled ? endpoint : void 0,
|
|
2362
|
+
embedded: enabled && (config.embedded ?? false),
|
|
2363
|
+
host,
|
|
2364
|
+
port,
|
|
2365
|
+
verbose: config.verbose ?? false
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
//#endregion
|
|
2370
|
+
//#region src/process-handlers.ts
|
|
2371
|
+
let removeOwnedHandlers = [];
|
|
2372
|
+
/**
|
|
2373
|
+
* Tracked separately from `removeOwnedHandlers` because the exit flush outlives
|
|
2374
|
+
* a call to `installProcessHandlers`, which clears that list before installing
|
|
2375
|
+
* its own signal listeners.
|
|
2376
|
+
*/
|
|
2377
|
+
let removeExitFlush;
|
|
2378
|
+
/**
|
|
2379
|
+
* Shared across every path that can end the process: signals, fatal errors and
|
|
2380
|
+
* a clean exit. They can overlap — a container stopping a job that has just
|
|
2381
|
+
* finished its work sends SIGTERM while the exit flush is still draining — and
|
|
2382
|
+
* a second shutdown would tear down queues the first is still using.
|
|
2383
|
+
*/
|
|
2384
|
+
let shutdownInFlight;
|
|
2385
|
+
/**
|
|
2386
|
+
* The clean-exit flush, while it runs. Doubles as the latch that keeps a
|
|
2387
|
+
* re-emitted `beforeExit` from flushing twice, and as the thing a shutdown
|
|
2388
|
+
* waits on rather than tearing down queues mid-drain.
|
|
2389
|
+
*/
|
|
2390
|
+
let exitFlushInFlight;
|
|
2391
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2e3;
|
|
2392
|
+
function runShutdownOnce(shutdown, timeoutMs) {
|
|
2393
|
+
if (shutdownInFlight) return shutdownInFlight;
|
|
2394
|
+
let timeoutHandle;
|
|
2395
|
+
const shutdownAttempt = Promise.resolve(exitFlushInFlight).then(shutdown).catch(() => void 0);
|
|
2396
|
+
const timeout = new Promise((resolve) => {
|
|
2397
|
+
timeoutHandle = setTimeout(resolve, timeoutMs);
|
|
2398
|
+
timeoutHandle.unref();
|
|
2399
|
+
});
|
|
2400
|
+
shutdownInFlight = Promise.race([shutdownAttempt, timeout]).then(() => {
|
|
2401
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
1620
2402
|
});
|
|
2403
|
+
return shutdownInFlight;
|
|
2404
|
+
}
|
|
2405
|
+
const DEFAULT_SIGNALS = ["SIGTERM", "SIGINT"];
|
|
2406
|
+
function signalExitCode(signal) {
|
|
2407
|
+
return 128 + constants.signals[signal];
|
|
1621
2408
|
}
|
|
1622
2409
|
/**
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
2410
|
+
* Surface a fatal error before shutting down.
|
|
2411
|
+
*
|
|
2412
|
+
* Registering an `uncaughtException` / `unhandledRejection` listener overrides
|
|
2413
|
+
* Node's default of printing the stack to stderr, so without this a crash under
|
|
2414
|
+
* `fatalErrors` would exit silently. Autotel's own logger is silent by default,
|
|
2415
|
+
* so we print to stderr directly to guarantee the crash stays visible.
|
|
1625
2416
|
*/
|
|
1626
|
-
function
|
|
1627
|
-
|
|
2417
|
+
function reportFatalError(error, event) {
|
|
2418
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2419
|
+
console.error(`[autotel] ${event}, flushing telemetry then exiting`, err);
|
|
2420
|
+
}
|
|
2421
|
+
function installProcessHandlers(config, shutdown) {
|
|
2422
|
+
uninstallProcessHandlers();
|
|
2423
|
+
const timeoutMs = config.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
2424
|
+
let exitScheduled = false;
|
|
2425
|
+
let resolvedExitCode = 0;
|
|
2426
|
+
let fatalLatched = false;
|
|
2427
|
+
const shutdownAndExit = (exitCode, fatal) => {
|
|
2428
|
+
if (fatal) {
|
|
2429
|
+
if (!fatalLatched) {
|
|
2430
|
+
resolvedExitCode = exitCode;
|
|
2431
|
+
fatalLatched = true;
|
|
2432
|
+
}
|
|
2433
|
+
} else if (!exitScheduled && !fatalLatched) resolvedExitCode = exitCode;
|
|
2434
|
+
if (exitScheduled) return;
|
|
2435
|
+
exitScheduled = true;
|
|
2436
|
+
runShutdownOnce(shutdown, timeoutMs).then(() => {
|
|
2437
|
+
process.exit(resolvedExitCode);
|
|
2438
|
+
});
|
|
2439
|
+
};
|
|
2440
|
+
for (const signal of config.signals ?? DEFAULT_SIGNALS) {
|
|
2441
|
+
const listener = () => {
|
|
2442
|
+
shutdownAndExit(signalExitCode(signal), false);
|
|
2443
|
+
};
|
|
2444
|
+
process.on(signal, listener);
|
|
2445
|
+
removeOwnedHandlers.push(() => {
|
|
2446
|
+
process.removeListener(signal, listener);
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
if (config.fatalErrors ?? true) {
|
|
2450
|
+
const uncaughtExceptionListener = (error) => {
|
|
2451
|
+
reportFatalError(error, "uncaughtException");
|
|
2452
|
+
shutdownAndExit(1, true);
|
|
2453
|
+
};
|
|
2454
|
+
const unhandledRejectionListener = (reason) => {
|
|
2455
|
+
reportFatalError(reason, "unhandledRejection");
|
|
2456
|
+
shutdownAndExit(1, true);
|
|
2457
|
+
};
|
|
2458
|
+
process.on("uncaughtException", uncaughtExceptionListener);
|
|
2459
|
+
process.on("unhandledRejection", unhandledRejectionListener);
|
|
2460
|
+
removeOwnedHandlers.push(() => {
|
|
2461
|
+
process.removeListener("uncaughtException", uncaughtExceptionListener);
|
|
2462
|
+
}, () => {
|
|
2463
|
+
process.removeListener("unhandledRejection", unhandledRejectionListener);
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
1628
2466
|
}
|
|
1629
2467
|
/**
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
2468
|
+
* Flush telemetry when the process runs to completion.
|
|
2469
|
+
*
|
|
2470
|
+
* The signal and fatal-error handlers above cover a process that is stopped or
|
|
2471
|
+
* that crashes. Neither fires when a script simply finishes: the event loop
|
|
2472
|
+
* drains and Node exits, taking whatever the batch span processor was still
|
|
2473
|
+
* holding with it. `beforeExit` is the only hook for that case, and it is the
|
|
2474
|
+
* one that matters for CLIs, cron jobs, CI steps and serverless handlers.
|
|
2475
|
+
*
|
|
2476
|
+
* A flush, never a shutdown: `beforeExit` fires on *any* event-loop drain, not
|
|
2477
|
+
* only the final one, so tearing the SDK down here would silently kill
|
|
2478
|
+
* telemetry in a process that goes on to do more work.
|
|
1632
2479
|
*/
|
|
1633
|
-
function
|
|
1634
|
-
|
|
2480
|
+
function installExitFlush(flushTelemetry, timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
|
|
2481
|
+
removeExitFlush?.();
|
|
2482
|
+
exitFlushInFlight = void 0;
|
|
2483
|
+
const listener = (code) => {
|
|
2484
|
+
if (exitFlushInFlight || shutdownInFlight) return;
|
|
2485
|
+
const deadline = setTimeout(() => {
|
|
2486
|
+
process.exit(process.exitCode ?? code);
|
|
2487
|
+
}, timeoutMs);
|
|
2488
|
+
exitFlushInFlight = flushTelemetry().catch(() => void 0).finally(() => {
|
|
2489
|
+
clearTimeout(deadline);
|
|
2490
|
+
});
|
|
2491
|
+
};
|
|
2492
|
+
process.on("beforeExit", listener);
|
|
2493
|
+
removeExitFlush = () => {
|
|
2494
|
+
process.removeListener("beforeExit", listener);
|
|
2495
|
+
removeExitFlush = void 0;
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
function uninstallProcessHandlers() {
|
|
2499
|
+
for (const removeHandler of removeOwnedHandlers) removeHandler();
|
|
2500
|
+
removeOwnedHandlers = [];
|
|
2501
|
+
removeExitFlush?.();
|
|
2502
|
+
shutdownInFlight = void 0;
|
|
2503
|
+
exitFlushInFlight = void 0;
|
|
1635
2504
|
}
|
|
1636
2505
|
|
|
1637
2506
|
//#endregion
|
|
@@ -2470,6 +3339,15 @@ function errorCode(cause) {
|
|
|
2470
3339
|
return asString(readProperty(cause, "code"));
|
|
2471
3340
|
}
|
|
2472
3341
|
/**
|
|
3342
|
+
* A bare "Flush timeout" left you with a stack trace into bundled internals and
|
|
3343
|
+
* nothing to act on. Name how long we waited and where we were sending, since
|
|
3344
|
+
* the usual cause is that nothing is listening at that endpoint.
|
|
3345
|
+
*/
|
|
3346
|
+
function flushTimeoutMessage(timeout) {
|
|
3347
|
+
const endpoint = getConfig()?.endpoint;
|
|
3348
|
+
return `Flush timeout after ${timeout}ms${endpoint === void 0 ? "" : ` sending to ${endpoint}`}. No collector acknowledged the export; check that one is running and reachable.`;
|
|
3349
|
+
}
|
|
3350
|
+
/**
|
|
2473
3351
|
* True when the error (or every error it wraps) is an unreachable-endpoint
|
|
2474
3352
|
* failure. Traverses `AggregateError.errors` and the `cause` chain, since the
|
|
2475
3353
|
* SDK often wraps the underlying network error.
|
|
@@ -2526,7 +3404,7 @@ async function flush(options) {
|
|
|
2526
3404
|
await Promise.race([doFlush().finally(() => {
|
|
2527
3405
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
2528
3406
|
}), new Promise((_, reject) => {
|
|
2529
|
-
timeoutHandle = setTimeout(() => reject(
|
|
3407
|
+
timeoutHandle = setTimeout(() => reject(new Error(flushTimeoutMessage(timeout))), timeout);
|
|
2530
3408
|
timeoutHandle.unref();
|
|
2531
3409
|
})]);
|
|
2532
3410
|
} catch (error) {
|
|
@@ -3350,4 +4228,4 @@ function getSdk() {
|
|
|
3350
4228
|
}
|
|
3351
4229
|
|
|
3352
4230
|
//#endregion
|
|
3353
|
-
export {
|
|
4231
|
+
export { getOrCreateCorrelationId as $, KeyTargetRateSampler as A, resolveSamplingPreset as B, AUTOTEL_SAMPLING_TAIL_EVALUATED as C, CompositeSampler as D, AlwaysSampler as E, debugCaptureRequested as F, getContextStorage as G, createTraceContext as H, extractLinksFromBatch as I, track as J, hasExplicitSpanStatus as K, hashUnitInterval as L, RandomSampler as M, UserIdSampler as N, DeterministicSampler as O, createLinkFromHeaders as P, getCorrelationStorage as Q, isForceKept as R, AUTOTEL_SAMPLING_RATE as S, AdaptiveSampler as T, defineBaggageSchema as U, samplingPresets as V, getActiveContextWithBaggage as W, generateCorrelationId as X, CORRELATION_ID_BAGGAGE_KEY as Y, getCorrelationId as Z, loadYamlConfigFromFile as _, isInitialized as a, TailSamplingSpanProcessor as b, flush as c, getEvents as d, runWithCorrelationId as et, resetEvents as f, loadYamlConfig as g, hasYamlConfig as h, init as i, NeverSampler as j, FeatureFlagSampler as k, shutdown as l, runInOperationContext as m, getLogger as n, setCorrelationIdInBaggage as nt, isLoggerLocked as o, getOperationContext as p, getEventQueue as q, getSdk as r, lockLogger as s, getConfig as t, setCorrelationId as tt, Event as u, createStringRedactor as v, AUTOTEL_SAMPLING_TAIL_KEEP as w, AUTOTEL_DEBUG_BAGGAGE_KEY as x, BaggageSpanProcessor as y, markForceKept as z };
|