UNPKG

autotel

Version:
442 lines (440 loc) 14 kB
import { TraceFlags } from "@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"; /** * Simple random sampler * * @example * ```typescript * new RandomSampler(0.1) // Sample 10% of requests * ``` */ var RandomSampler = class { sampleRate; constructor(sampleRate) { this.sampleRate = sampleRate; if (sampleRate < 0 || sampleRate > 1) throw new Error("Sample rate must be between 0 and 1"); } shouldSample(_context) { return Math.random() < this.sampleRate; } }; /** * 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 & 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) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.codePointAt(i) ?? 0; hash = (hash << 5) - hash + char; hash = hash & hash; } return Math.abs(hash) / 2147483647; } }; /** * 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 export { AUTOTEL_SAMPLING_TAIL_EVALUATED, AUTOTEL_SAMPLING_TAIL_KEEP, AdaptiveSampler, AlwaysSampler, CompositeSampler, FeatureFlagSampler, NeverSampler, RandomSampler, UserIdSampler, createLinkFromHeaders, extractLinksFromBatch, resolveSamplingPreset, samplingPresets }; //# sourceMappingURL=sampling.js.map