autotel
Version:
Write Once, Observe Anywhere
603 lines (601 loc) • 19.7 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
let _opentelemetry_api = require("@opentelemetry/api");
//#region src/sampling.ts
/**
* Tail sampling attribute keys (autotel-internal, not OTel semconv)
*/
const AUTOTEL_SAMPLING_TAIL_KEEP = "autotel.sampling.tail.keep";
const AUTOTEL_SAMPLING_TAIL_EVALUATED = "autotel.sampling.tail.evaluated";
/**
* How many events each kept event stands for, expressed as "1 in N".
*
* A query that counts sampled spans undercounts the population. Multiplying
* each kept event by this rate restores the estimate. Autotel records the
* attribute only when N exceeds 1, so fully captured spans stay clean.
*/
const AUTOTEL_SAMPLING_RATE = "autotel.sampling.rate";
/**
* Convert a keep probability into the "1 in N" rate reported on spans.
*
* Keeping 10% of traces means each survivor stands for 10, so the two numbers
* are reciprocals and easy to publish the wrong way round.
*/
function toSampleRate(probability) {
return probability > 0 ? 1 / probability : 0;
}
/**
* Map a string to a stable, evenly spread position in the unit interval.
*
* Two processes that hash the same key reach the same number, which is what
* lets independent services agree on one trace's sampling decision.
*
* The spread matters as much as the stability. Real sampling keys share long
* prefixes: `user_1000`, `user_1001`, `checkout-trace-0001`. A plain
* multiply-and-add hash lets that shared prefix dominate the high bits, so a
* whole key family lands in one narrow band and a rate of 0.1 keeps all of
* them or none of them. FNV-1a followed by the murmur3 finalizer mixes the
* low bits back through the word, so keys that differ in one character land
* far apart.
*/
function hashUnitInterval(value) {
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.codePointAt(i) ?? 0;
hash = Math.imul(hash, 16777619);
}
hash ^= hash >>> 16;
hash = Math.imul(hash, 2246822507);
hash ^= hash >>> 13;
hash = Math.imul(hash, 3266489909);
hash ^= hash >>> 16;
return (hash >>> 0) / 4294967296;
}
/**
* Simple random sampler
*
* @example
* ```typescript
* new RandomSampler(0.1) // Sample 10% of requests
* ```
*/
var RandomSampler = class {
rate;
constructor(rate) {
this.rate = rate;
if (rate < 0 || rate > 1) throw new Error("Sample rate must be between 0 and 1");
}
shouldSample(_context) {
return Math.random() < this.rate;
}
sampleRate() {
return toSampleRate(this.rate);
}
};
/**
* Always sample (100% tracing)
*/
var AlwaysSampler = class {
shouldSample(_context) {
return true;
}
};
/**
* Never sample (0% tracing)
*/
var NeverSampler = class {
shouldSample(_context) {
return false;
}
};
/**
* Adaptive sampler that always traces errors and slow requests
*
* This is the recommended sampler for production use.
* It ensures you never miss critical issues while keeping costs down.
*
* Strategy:
* - Always trace errors (critical for debugging)
* - Always trace slow requests (performance issues)
* - Use baseline sample rate for successful fast requests
*
* **IMPORTANT - Tail Sampling Requirement:**
* This sampler uses tail sampling (makes decisions AFTER execution).
* You MUST use TailSamplingSpanProcessor for it to work correctly:
*
* - If using initInstrumentation(): TailSamplingSpanProcessor is auto-configured
* - If using custom TracerProvider: You MUST manually register TailSamplingSpanProcessor
*
* Without TailSamplingSpanProcessor, ALL spans are exported (defeating the cost savings).
*
* @see TailSamplingSpanProcessor
* @see README.md "Tail Sampling with Custom Providers" section
*
* @example
* ```typescript
* new AdaptiveSampler({
* baselineSampleRate: 0.1, // 10% of normal requests
* slowThresholdMs: 1000, // Requests > 1s are "slow"
* alwaysSampleErrors: true, // Always trace errors
* alwaysSampleSlow: true // Always trace slow requests
* })
* ```
*/
var AdaptiveSampler = class {
baselineSampleRate;
slowThresholdMs;
alwaysSampleErrors;
alwaysSampleSlow;
linksBased;
linksRate;
logger;
samplingDecisions = /* @__PURE__ */ new WeakMap();
operationResults = /* @__PURE__ */ new WeakMap();
constructor(options = {}) {
this.baselineSampleRate = options.baselineSampleRate ?? .1;
this.slowThresholdMs = options.slowThresholdMs ?? 1e3;
this.alwaysSampleErrors = options.alwaysSampleErrors ?? true;
this.alwaysSampleSlow = options.alwaysSampleSlow ?? true;
this.linksBased = options.linksBased ?? false;
this.linksRate = options.linksRate ?? 1;
this.logger = options.logger;
if (this.baselineSampleRate < 0 || this.baselineSampleRate > 1) throw new Error("Baseline sample rate must be between 0 and 1");
if (this.linksRate < 0 || this.linksRate > 1) throw new Error("Links rate must be between 0 and 1");
}
needsTailSampling() {
return true;
}
shouldSample(context) {
const baselineDecision = Math.random() < this.baselineSampleRate;
this.samplingDecisions.set(context.args, baselineDecision);
return true;
}
/**
* Check if any links point to sampled spans.
*
* A span is considered linked to a sampled span if any of its links
* have trace_flags with the sampled bit set (0x01).
*
* @param links - Array of span links to check
* @returns true if any linked span is sampled, false otherwise
*/
hasSampledLink(links) {
if (!links || links.length === 0) return false;
return links.some((link) => link.context && (link.context.traceFlags & _opentelemetry_api.TraceFlags.SAMPLED) !== 0);
}
/**
* Re-evaluate sampling decision after operation completes
*
* This allows us to always capture errors and slow requests,
* even if they weren't initially sampled.
*
* @param context - Sampling context
* @param result - Operation result
* @returns true if this operation should be kept (not discarded)
*/
shouldKeepTrace(context, result) {
const baselineDecision = this.samplingDecisions.get(context.args) ?? false;
if (this.alwaysSampleErrors && !result.success) {
if (!baselineDecision) this.logger?.debug({
operation: context.operationName,
error: result.error?.message
}, "Adaptive sampling: Keeping error trace");
return true;
}
if (this.alwaysSampleSlow && result.duration >= this.slowThresholdMs) {
if (!baselineDecision) this.logger?.debug({
operation: context.operationName,
duration: result.duration
}, "Adaptive sampling: Keeping slow trace");
return true;
}
if (this.linksBased && context.links && this.hasSampledLink(context.links)) {
const keepLinked = Math.random() < this.linksRate;
if (keepLinked && !baselineDecision) this.logger?.debug({
operation: context.operationName,
linkCount: context.links.length
}, "Adaptive sampling: Keeping trace due to sampled link");
return keepLinked;
}
return baselineDecision;
}
};
/**
* User-based sampler for consistent tracing
*
* Always samples requests from specific user IDs.
* Useful for debugging specific user issues or monitoring VIP users.
*
* @example
* ```typescript
* new UserIdSampler({
* baselineSampleRate: 0.01, // 1% of normal users
* alwaysSampleUsers: ['vip_123'], // Always trace VIP users
* extractUserId: (args) => args[0]?.userId // Extract user ID from first arg
* })
* ```
*/
var UserIdSampler = class {
baselineSampleRate;
alwaysSampleUsers;
extractUserId;
logger;
constructor(options) {
this.baselineSampleRate = options.baselineSampleRate ?? .1;
this.alwaysSampleUsers = new Set(options.alwaysSampleUsers || []);
this.extractUserId = options.extractUserId;
this.logger = options.logger;
}
shouldSample(context) {
const userId = this.extractUserId(context.args);
if (userId && this.alwaysSampleUsers.has(userId)) {
this.logger?.debug({
operation: context.operationName,
userId
}, "Sampling user request");
return true;
}
if (userId) return this.hashString(userId) < this.baselineSampleRate;
return Math.random() < this.baselineSampleRate;
}
/**
* Add user IDs to always-sample list
*/
addAlwaysSampleUsers(...userIds) {
for (const userId of userIds) this.alwaysSampleUsers.add(userId);
}
/**
* Remove user IDs from always-sample list
*/
removeAlwaysSampleUsers(...userIds) {
for (const userId of userIds) this.alwaysSampleUsers.delete(userId);
}
/**
* Simple hash function for consistent user sampling
*/
hashString(str) {
return hashUnitInterval(str);
}
};
/**
* Consistent sampler: every service reaches the same verdict for one trace.
*
* `RandomSampler` rolls the dice per process, so an upstream service can keep
* a trace that its downstream drops, leaving a waterfall with holes in it.
* Hashing a key that travels with the request removes the disagreement. Pass
* the trace id, or any identifier every hop already shares.
*
* @example
* ```typescript
* new DeterministicSampler({
* sampleRate: 0.1,
* key: (context) => trace.getActiveSpan()?.spanContext().traceId,
* })
* ```
*/
var DeterministicSampler = class {
rate;
key;
constructor(options) {
if (options.sampleRate < 0 || options.sampleRate > 1) throw new Error("Sample rate must be between 0 and 1");
this.rate = options.sampleRate;
this.key = options.key;
}
shouldSample(context) {
const key = this.key(context);
if (key === void 0) return Math.random() < this.rate;
return hashUnitInterval(key) < this.rate;
}
sampleRate() {
return toSampleRate(this.rate);
}
};
/** Bucket for keys seen after the tracked map fills up. */
const OVERFLOW_KEY = "__overflow__";
/**
* Per-key target-rate sampler for workloads with uneven traffic.
*
* A single rate serves a skewed workload badly: 1% floods storage with the
* busiest endpoint and still loses the rare tenant whose failures you need.
* This sampler counts traffic per key over a rolling window, then sets each
* key its own rate so every key contributes roughly `targetPerKey` events.
* Quiet keys survive intact; loud keys get thinned.
*
* The first window keeps everything, because no traffic history exists yet.
* Rates take effect from the second window onward.
*
* @example
* ```typescript
* new KeyTargetRateSampler({
* key: (context) => context.operationName,
* targetPerKey: 10, // ~10 events per key per window
* windowMs: 30_000,
* })
* ```
*/
var KeyTargetRateSampler = class {
key;
targetPerKey;
windowMs;
maxKeys;
counts = /* @__PURE__ */ new Map();
rates = /* @__PURE__ */ new Map();
windowStart = Date.now();
constructor(options) {
this.key = options.key;
this.targetPerKey = options.targetPerKey ?? 10;
this.windowMs = options.windowMs ?? 3e4;
this.maxKeys = options.maxKeys ?? 1e3;
if (this.targetPerKey <= 0) throw new Error("Target per key must be greater than 0");
if (this.windowMs <= 0) throw new Error("Window must be greater than 0");
}
/** Turn the window's observed counts into the next window's rates. */
roll(now) {
if (now - this.windowStart < this.windowMs) return;
const rates = /* @__PURE__ */ new Map();
for (const [key, count] of this.counts) rates.set(key, Math.max(1, count / this.targetPerKey));
this.rates = rates;
this.counts = /* @__PURE__ */ new Map();
this.windowStart = now;
}
/**
* Resolve the key, collapsing into one bucket once the map is full.
*
* An unbounded key function would otherwise grow the map without limit,
* which turns a sampler meant to cut cost into a memory leak.
*/
resolveKey(context) {
const key = this.key(context) ?? OVERFLOW_KEY;
if (this.counts.has(key) || this.counts.size < this.maxKeys) return key;
return OVERFLOW_KEY;
}
shouldSample(context) {
this.roll(Date.now());
const key = this.resolveKey(context);
this.counts.set(key, (this.counts.get(key) ?? 0) + 1);
const rate = this.rates.get(key) ?? 1;
return rate <= 1 || Math.random() < 1 / rate;
}
sampleRate(context) {
return this.rates.get(this.resolveKey(context)) ?? 1;
}
};
/**
* Composite sampler that combines multiple samplers
*
* Samples if ANY of the child samplers returns true.
*
* @example
* ```typescript
* new CompositeSampler([
* new UserIdSampler({ extractUserId: (args) => args[0]?.userId }),
* new AdaptiveSampler({ baselineSampleRate: 0.1 })
* ])
* ```
*/
var CompositeSampler = class {
samplers;
constructor(samplers) {
this.samplers = samplers;
if (samplers.length === 0) throw new Error("CompositeSampler requires at least one child sampler");
}
shouldSample(context) {
return this.samplers.some((sampler) => sampler.shouldSample(context));
}
};
/**
* Feature flag sampler
*
* Always samples requests with specific feature flags enabled.
* Perfect for correlating A/B test experiments with metrics.
*
* @example
* ```typescript
* new FeatureFlagSampler({
* baselineSampleRate: 0.01,
* alwaysSampleFlags: ['new_checkout', 'experimental_ui'],
* extractFlags: (args, metadata) => metadata?.featureFlags
* })
* ```
*/
var FeatureFlagSampler = class {
baselineSampleRate;
alwaysSampleFlags;
extractFlags;
logger;
constructor(options) {
this.baselineSampleRate = options.baselineSampleRate ?? .1;
this.alwaysSampleFlags = new Set(options.alwaysSampleFlags || []);
this.extractFlags = options.extractFlags;
this.logger = options.logger;
}
shouldSample(context) {
const flags = this.extractFlags(context.args, context.metadata);
if (flags && flags.some((flag) => this.alwaysSampleFlags.has(flag))) {
this.logger?.debug({
operation: context.operationName,
flags
}, "Sampling feature flag request");
return true;
}
return Math.random() < this.baselineSampleRate;
}
/**
* Add feature flags to always-sample list
*/
addAlwaysSampleFlags(...flags) {
for (const flag of flags) this.alwaysSampleFlags.add(flag);
}
/**
* Remove feature flags from always-sample list
*/
removeAlwaysSampleFlags(...flags) {
for (const flag of flags) this.alwaysSampleFlags.delete(flag);
}
};
/**
* Sampling preset factories.
*
* For most users, the string shorthand on `init()` is simpler:
* ```typescript
* init({ service: 'my-app', sampling: 'production' })
* ```
*
* Use factories when you need to customize:
* ```typescript
* init({ service: 'my-app', sampler: samplingPresets.production({ baselineSampleRate: 0.05 }) })
* ```
*/
const samplingPresets = {
/** Capture everything — best for local development and debugging */
development: () => new AlwaysSampler(),
/** Only bad outcomes — zero baseline, errors always kept */
errorsOnly: () => new AdaptiveSampler({
baselineSampleRate: 0,
alwaysSampleErrors: true
}),
/**
* Balanced production defaults — 10% baseline + errors + slow traces.
* Pass overrides to tune (uses the same option names as AdaptiveSampler).
*/
production: (overrides) => new AdaptiveSampler({
baselineSampleRate: .1,
alwaysSampleErrors: true,
alwaysSampleSlow: true,
slowThresholdMs: 1e3,
...overrides
}),
/** Disable sampling entirely */
off: () => new NeverSampler()
};
/**
* Resolve a preset string to a Sampler instance.
* Used internally by `init()` when `sampling` string is provided.
*
* @throws Error if preset is not recognized
*/
function resolveSamplingPreset(preset) {
switch (preset) {
case "development": return samplingPresets.development();
case "errors-only": return samplingPresets.errorsOnly();
case "production": return samplingPresets.production();
case "off": return samplingPresets.off();
default: throw new Error(`Unknown sampling preset: "${preset}". Valid presets: development, errors-only, production, off`);
}
}
/**
* Create a Link from W3C trace context headers (e.g., from a message queue).
*
* This is useful for message consumers that need to link to the producer span.
* The headers should contain at least a `traceparent` header in W3C format.
*
* @param headers - Dictionary containing traceparent/tracestate headers
* @param attributes - Optional attributes for the link
* @returns Link object if context could be extracted, null otherwise
*
* @example
* ```typescript
* // In a Kafka consumer
* const headers = { traceparent: '00-abc123...-def456...-01' };
* const link = createLinkFromHeaders(headers);
* if (link) {
* // Use with tracer.startActiveSpan options or ctx.addLink()
* tracer.startActiveSpan('process.message', { links: [link] }, span => { ... });
* }
* ```
*/
function createLinkFromHeaders(headers, attributes) {
const traceparent = headers.traceparent || headers["traceparent"];
if (!traceparent) return null;
const spanContext = parseTraceparent(traceparent);
if (!spanContext || !isValidSpanContext(spanContext)) return null;
return {
context: spanContext,
attributes: attributes ?? {}
};
}
/**
* Extract Links from a batch of messages for fan-in scenarios.
*
* Useful for batch processing where multiple producer spans should be linked.
* This enables tracing causality in event-driven architectures where a single
* consumer processes messages from multiple producers.
*
* @param messages - List of message objects
* @param headersKey - Key in each message containing trace headers (default: 'headers')
* @returns List of Link objects for all valid trace contexts
*
* @example
* ```typescript
* // Processing a batch of SQS/Kafka messages
* const messages = [
* { body: '...', headers: { traceparent: '...' } },
* { body: '...', headers: { traceparent: '...' } },
* ];
* const links = extractLinksFromBatch(messages);
*
* tracer.startActiveSpan('process.batch', { links }, span => {
* for (const msg of messages) {
* processMessage(msg);
* }
* });
* ```
*/
function extractLinksFromBatch(messages, headersKey = "headers") {
const links = [];
for (const msg of messages) {
const msgHeaders = msg[headersKey];
if (msgHeaders && typeof msgHeaders === "object" && msgHeaders !== null) {
const link = createLinkFromHeaders(msgHeaders, { "messaging.batch.message_index": links.length });
if (link) links.push(link);
}
}
return links;
}
/**
* Parse W3C traceparent header into SpanContext
* Format: version-traceId-spanId-traceFlags (e.g., 00-abc123...-def456...-01)
*
* @see https://www.w3.org/TR/trace-context/#traceparent-header
*/
function parseTraceparent(traceparent) {
const match = traceparent.match(/^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i);
if (!match || match.length < 5) return null;
const version = match[1];
const traceId = match[2];
const spanId = match[3];
const flags = match[4];
if (!version || !traceId || !spanId || !flags) return null;
if (version === "ff") return null;
return {
traceId,
spanId,
traceFlags: Number.parseInt(flags, 16),
isRemote: true
};
}
/**
* Check if a SpanContext is valid (has non-zero trace and span IDs)
*/
function isValidSpanContext(spanContext) {
if (!spanContext) return false;
return spanContext.traceId !== "00000000000000000000000000000000" && spanContext.spanId !== "0000000000000000";
}
//#endregion
exports.AUTOTEL_SAMPLING_RATE = AUTOTEL_SAMPLING_RATE;
exports.AUTOTEL_SAMPLING_TAIL_EVALUATED = AUTOTEL_SAMPLING_TAIL_EVALUATED;
exports.AUTOTEL_SAMPLING_TAIL_KEEP = AUTOTEL_SAMPLING_TAIL_KEEP;
exports.AdaptiveSampler = AdaptiveSampler;
exports.AlwaysSampler = AlwaysSampler;
exports.CompositeSampler = CompositeSampler;
exports.DeterministicSampler = DeterministicSampler;
exports.FeatureFlagSampler = FeatureFlagSampler;
exports.KeyTargetRateSampler = KeyTargetRateSampler;
exports.NeverSampler = NeverSampler;
exports.RandomSampler = RandomSampler;
exports.UserIdSampler = UserIdSampler;
exports.createLinkFromHeaders = createLinkFromHeaders;
exports.extractLinksFromBatch = extractLinksFromBatch;
exports.hashUnitInterval = hashUnitInterval;
exports.resolveSamplingPreset = resolveSamplingPreset;
exports.samplingPresets = samplingPresets;
//# sourceMappingURL=sampling.cjs.map