UNPKG

autotel

Version:
603 lines (601 loc) 19.7 kB
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