UNPKG

autotel

Version:
1,417 lines (1,405 loc) 123 kB
const require_rolldown_runtime = require('./rolldown-runtime-C_NdSu1c.cjs'); const require_tracer_provider = require('./tracer-provider.cjs'); const require_config = require('./config.cjs'); const require_sampling = require('./sampling.cjs'); const require_node_require = require('./node-require-PFqDihze.cjs'); const require_tail_sampling_processor = require('./tail-sampling-processor.cjs'); const require_filtering_span_processor = require('./filtering-span-processor.cjs'); const require_policy = require('./policy.cjs'); const require_span_name_normalizer = require('./span-name-normalizer.cjs'); const require_attribute_redacting_processor = require('./attribute-redacting-processor.cjs'); const require_pretty_console_exporter = require('./pretty-console-exporter-CMzlrRNg.cjs'); const require_yaml_config = require('./yaml-config.cjs'); const require_canonical_log_line_processor = require('./canonical-log-line-processor-DxtBGZIi.cjs'); const require_structured_error = require('./structured-error-CHg7DoIQ.cjs'); const require_metric = require('./metric.cjs'); let _opentelemetry_sdk_node = require("@opentelemetry/sdk-node"); let _opentelemetry_sdk_trace_base = require("@opentelemetry/sdk-trace-base"); let _opentelemetry_resources = require("@opentelemetry/resources"); let _opentelemetry_semantic_conventions = require("@opentelemetry/semantic-conventions"); let _opentelemetry_api = require("@opentelemetry/api"); let _opentelemetry_sdk_metrics = require("@opentelemetry/sdk-metrics"); let _opentelemetry_sdk_logs = require("@opentelemetry/sdk-logs"); let node_os = require("node:os"); let node_async_hooks = require("node:async_hooks"); node_async_hooks = require_rolldown_runtime.__toESM(node_async_hooks, 1); let _opentelemetry_exporter_metrics_otlp_http = require("@opentelemetry/exporter-metrics-otlp-http"); let _opentelemetry_exporter_trace_otlp_http = require("@opentelemetry/exporter-trace-otlp-http"); let _opentelemetry_exporter_logs_otlp_http = require("@opentelemetry/exporter-logs-otlp-http"); //#region src/init-logger.ts const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; /** Silent logger used until and unless the application opts into diagnostics. */ const silentLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; /** Apply the resolved silence and minimum-level settings to a logger. */ function wrapLogger(base, silent, minLevel) { if (silent) return silentLogger; const threshold = LOG_LEVELS[minLevel]; const wrap = (fn, level) => { if (LOG_LEVELS[level] < threshold) return (() => {}); return ((...args) => fn(...args)); }; return { debug: wrap(base.debug, "debug"), info: wrap(base.info, "info"), warn: wrap(base.warn, "warn"), error: wrap(base.error, "error") }; } //#endregion //#region src/posthog-logs.ts var RedactingLogRecordProcessor = class { wrapped; redact; constructor(wrapped, redact) { this.wrapped = wrapped; this.redact = redact; } onEmit(logRecord, context) { if (logRecord.body && typeof logRecord.body === "string") logRecord.body = this.redact(logRecord.body); if (logRecord.attributes) { for (const [key, value] of Object.entries(logRecord.attributes)) if (typeof value === "string") logRecord.attributes[key] = this.redact(value); else if (Array.isArray(value)) logRecord.attributes[key] = value.map((item) => typeof item === "string" ? this.redact(item) : item); } this.wrapped.onEmit(logRecord, context); } shutdown() { return this.wrapped.shutdown(); } forceFlush() { return this.wrapped.forceFlush(); } }; /** * Build log record processors for PostHog OTLP logs integration. * * Resolution order: * 1. config.url if provided * 2. POSTHOG_LOGS_URL env var * 3. Empty array (disabled) */ function buildPostHogLogProcessors(config, stringRedactor) { const url = config?.url || process.env.POSTHOG_LOGS_URL; if (!url) return []; const sdkLogs = require_node_require.safeRequire("@opentelemetry/sdk-logs"); const exporterModule = require_node_require.safeRequire("@opentelemetry/exporter-logs-otlp-http"); if (!sdkLogs || !exporterModule) return []; const exporter = new exporterModule.OTLPLogExporter({ url }); let processor = new sdkLogs.BatchLogRecordProcessor({ exporter }); if (stringRedactor) processor = new RedactingLogRecordProcessor(processor, stringRedactor); return [processor]; } //#endregion //#region src/baggage-span-processor.ts /** * Span processor that automatically copies baggage entries to span attributes * * This makes baggage visible in trace UIs (Jaeger, Grafana, DataDog, etc.) * without manually calling ctx.setAttribute() for each baggage entry. * * @example Enable in init() * ```typescript * init({ * service: 'my-app', * baggage: true // Uses default 'baggage.' prefix * }); * * // Now baggage automatically appears as span attributes * await withBaggage({ * baggage: { 'tenant.id': 't1', 'user.id': 'u1' }, * fn: async () => { * // Span has baggage.tenant.id and baggage.user.id attributes! * } * }); * ``` * * @example Custom prefix * ```typescript * init({ * service: 'my-app', * baggage: 'ctx' // Uses 'ctx.' prefix * }); * // Creates attributes: ctx.tenant.id, ctx.user.id * ``` */ var BaggageSpanProcessor = class { prefix; constructor(options = {}) { this.prefix = options.prefix ?? "baggage."; } onStart(span, parentContext) { let baggage = _opentelemetry_api.propagation.getBaggage(parentContext); if (!baggage) baggage = _opentelemetry_api.propagation.getBaggage(_opentelemetry_api.context.active()); if (!baggage) try { const { getActiveContextWithBaggage } = require_node_require.requireModule("./trace-context"); const storedContext = getActiveContextWithBaggage(); baggage = _opentelemetry_api.propagation.getBaggage(storedContext); } catch {} if (!baggage) return; for (const [key, entry] of baggage.getAllEntries()) span.setAttribute(`${this.prefix}${key}`, entry.value); } onEnd(_span) {} async shutdown() {} async forceFlush() {} }; //#endregion //#region src/redact-values.ts /** Standalone string redaction for use outside the span processor pipeline. */ function createStringRedactor(config) { const resolved = typeof config === "string" ? require_attribute_redacting_processor.REDACTOR_PRESETS[config] : config; const valuePatterns = resolved.valuePatterns ?? []; const defaultReplacement = resolved.replacement ?? "[REDACTED]"; return (value) => { let result = value; for (const { pattern, replacement, mask } of valuePatterns) { pattern.lastIndex = 0; result = mask ? result.replaceAll(pattern, (match) => mask(match)) : result.replaceAll(pattern, replacement ?? defaultReplacement); } return result; }; } //#endregion //#region src/env-config.ts /** * Validate URL format */ function isValidUrl(urlString) { try { const url = new URL(urlString); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } /** * Resolve OpenTelemetry environment variables from process.env */ function resolveOtelEnv() { const env = {}; if (process.env.OTEL_SERVICE_NAME) { const value = process.env.OTEL_SERVICE_NAME.trim(); if (value) env.OTEL_SERVICE_NAME = value; } if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) { const value = process.env.OTEL_EXPORTER_OTLP_ENDPOINT.trim(); if (value && isValidUrl(value)) env.OTEL_EXPORTER_OTLP_ENDPOINT = value; } if (process.env.OTEL_EXPORTER_OTLP_HEADERS) { const value = process.env.OTEL_EXPORTER_OTLP_HEADERS.trim(); if (value) env.OTEL_EXPORTER_OTLP_HEADERS = value; } if (process.env.OTEL_RESOURCE_ATTRIBUTES) { const value = process.env.OTEL_RESOURCE_ATTRIBUTES.trim(); if (value) env.OTEL_RESOURCE_ATTRIBUTES = value; } if (process.env.OTEL_EXPORTER_OTLP_PROTOCOL) { const value = process.env.OTEL_EXPORTER_OTLP_PROTOCOL.trim().toLowerCase(); if (value === "http" || value === "http/json") env.OTEL_EXPORTER_OTLP_PROTOCOL = "http"; else if (value === "http/protobuf" || value === "grpc") env.OTEL_EXPORTER_OTLP_PROTOCOL = value; } if (process.env.OTEL_TRACES_SAMPLER) { const value = process.env.OTEL_TRACES_SAMPLER.trim(); if (value) env.OTEL_TRACES_SAMPLER = value; } if (process.env.OTEL_TRACES_SAMPLER_ARG) { const value = process.env.OTEL_TRACES_SAMPLER_ARG.trim(); if (value) env.OTEL_TRACES_SAMPLER_ARG = value; } return env; } function parseRatioSamplerArg(samplerName, samplerArg) { if (samplerArg === void 0) return 1; const ratio = Number(samplerArg); if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) { console.error(`[autotel] Invalid OTEL_TRACES_SAMPLER_ARG="${samplerArg}" for ${samplerName}. Expected a number in [0..1]. Falling back to 1.0.`); return 1; } return ratio; } function warnOnUnusedSamplerArg(samplerName, samplerArg) { if (samplerArg !== void 0) console.error(`[autotel] OTEL_TRACES_SAMPLER_ARG is not used by OTEL_TRACES_SAMPLER="${samplerName}". Ignoring value "${samplerArg}".`); } function createSamplerFromEnv(env) { const samplerName = env.OTEL_TRACES_SAMPLER; if (!samplerName) return; switch (samplerName) { case "always_on": warnOnUnusedSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG); return new _opentelemetry_sdk_trace_base.AlwaysOnSampler(); case "always_off": warnOnUnusedSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG); return new _opentelemetry_sdk_trace_base.AlwaysOffSampler(); case "traceidratio": return new _opentelemetry_sdk_trace_base.TraceIdRatioBasedSampler(parseRatioSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG)); case "parentbased_always_on": warnOnUnusedSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG); return new _opentelemetry_sdk_trace_base.ParentBasedSampler({ root: new _opentelemetry_sdk_trace_base.AlwaysOnSampler() }); case "parentbased_always_off": warnOnUnusedSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG); return new _opentelemetry_sdk_trace_base.ParentBasedSampler({ root: new _opentelemetry_sdk_trace_base.AlwaysOffSampler() }); case "parentbased_traceidratio": return new _opentelemetry_sdk_trace_base.ParentBasedSampler({ root: new _opentelemetry_sdk_trace_base.TraceIdRatioBasedSampler(parseRatioSamplerArg(samplerName, env.OTEL_TRACES_SAMPLER_ARG)) }); case "jaeger_remote": case "parentbased_jaeger_remote": case "xray": console.error(`[autotel] OTEL_TRACES_SAMPLER="${samplerName}" is not supported yet by autotel. Falling back to the next sampler source.`); return; default: console.error(`[autotel] Unknown OTEL_TRACES_SAMPLER="${samplerName}". Falling back to the next sampler source.`); return; } } /** * Parse OTEL_RESOURCE_ATTRIBUTES from comma-separated key=value pairs * Example: "service.version=1.0.0,deployment.environment=production" */ function parseResourceAttributes(input) { if (!input || input.trim() === "") return {}; const attributes = {}; const pairs = input.split(","); for (const pair of pairs) { const trimmedPair = pair.trim(); if (!trimmedPair) continue; const equalIndex = trimmedPair.indexOf("="); if (equalIndex === -1) continue; const key = trimmedPair.slice(0, equalIndex).trim(); const value = trimmedPair.slice(equalIndex + 1).trim(); if (key && value) attributes[key] = value; } return attributes; } /** * Parse OTEL_EXPORTER_OTLP_HEADERS from comma-separated key=value pairs * Example: "api-key=secret123,x-custom-header=value" */ function parseOtlpHeaders(input) { if (!input || input.trim() === "") return {}; const headers = {}; const pairs = input.split(","); for (const pair of pairs) { const trimmedPair = pair.trim(); if (!trimmedPair) continue; const equalIndex = trimmedPair.indexOf("="); if (equalIndex === -1) continue; const key = trimmedPair.slice(0, equalIndex).trim(); const value = trimmedPair.slice(equalIndex + 1).trim(); if (key && value) headers[key] = value; } return headers; } /** * Convert resolved environment variables to config */ function envToConfig(env) { const config = {}; if (env.OTEL_SERVICE_NAME) config.service = env.OTEL_SERVICE_NAME; if (env.OTEL_EXPORTER_OTLP_ENDPOINT) config.endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; if (env.OTEL_EXPORTER_OTLP_PROTOCOL) config.protocol = env.OTEL_EXPORTER_OTLP_PROTOCOL; if (env.OTEL_EXPORTER_OTLP_HEADERS) config.headers = parseOtlpHeaders(env.OTEL_EXPORTER_OTLP_HEADERS); const resourceAttrs = parseResourceAttributes(env.OTEL_RESOURCE_ATTRIBUTES); if (Object.keys(resourceAttrs).length > 0) config.resourceAttributes = resourceAttrs; const sampler = createSamplerFromEnv(env); if (sampler) config.otelSampler = sampler; return config; } /** * Main function to resolve config from environment variables */ function resolveConfigFromEnv() { return envToConfig(resolveOtelEnv()); } //#endregion //#region src/devtools.ts const defaultHost = "127.0.0.1"; const defaultPort = 4318; function resolveDevtoolsConfig(config) { if (!config) return { enabled: false, endpoint: void 0, embedded: false, host: defaultHost, port: defaultPort, verbose: false }; if (config === true) return { enabled: true, endpoint: `http://${defaultHost}:${defaultPort}`, embedded: false, host: defaultHost, port: defaultPort, verbose: false }; const enabled = config.enabled ?? true; const host = config.host ?? defaultHost; const port = config.port ?? defaultPort; const endpoint = config.endpoint ?? `http://${host}:${port}`; return { enabled, endpoint: enabled ? endpoint : void 0, embedded: enabled && (config.embedded ?? false), host, port, verbose: config.verbose ?? false }; } //#endregion //#region src/process-handlers.ts let removeOwnedHandlers = []; /** * Tracked separately from `removeOwnedHandlers` because the exit flush outlives * a call to `installProcessHandlers`, which clears that list before installing * its own signal listeners. */ let removeExitFlush; /** * Shared across every path that can end the process: signals, fatal errors and * a clean exit. They can overlap — a container stopping a job that has just * finished its work sends SIGTERM while the exit flush is still draining — and * a second shutdown would tear down queues the first is still using. */ let shutdownInFlight; /** * The clean-exit flush, while it runs. Doubles as the latch that keeps a * re-emitted `beforeExit` from flushing twice, and as the thing a shutdown * waits on rather than tearing down queues mid-drain. */ let exitFlushInFlight; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2e3; function runShutdownOnce(shutdown, timeoutMs) { if (shutdownInFlight) return shutdownInFlight; let timeoutHandle; const shutdownAttempt = Promise.resolve(exitFlushInFlight).then(shutdown).catch(() => void 0); const timeout = new Promise((resolve) => { timeoutHandle = setTimeout(resolve, timeoutMs); timeoutHandle.unref(); }); shutdownInFlight = Promise.race([shutdownAttempt, timeout]).then(() => { if (timeoutHandle) clearTimeout(timeoutHandle); }); return shutdownInFlight; } const DEFAULT_SIGNALS = ["SIGTERM", "SIGINT"]; function signalExitCode(signal) { return 128 + node_os.constants.signals[signal]; } /** * Surface a fatal error before shutting down. * * Registering an `uncaughtException` / `unhandledRejection` listener overrides * Node's default of printing the stack to stderr, so without this a crash under * `fatalErrors` would exit silently. Autotel's own logger is silent by default, * so we print to stderr directly to guarantee the crash stays visible. */ function reportFatalError(error, event) { const err = error instanceof Error ? error : new Error(String(error)); console.error(`[autotel] ${event}, flushing telemetry then exiting`, err); } function installProcessHandlers(config, shutdown) { uninstallProcessHandlers(); const timeoutMs = config.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS; let exitScheduled = false; let resolvedExitCode = 0; let fatalLatched = false; const shutdownAndExit = (exitCode, fatal) => { if (fatal) { if (!fatalLatched) { resolvedExitCode = exitCode; fatalLatched = true; } } else if (!exitScheduled && !fatalLatched) resolvedExitCode = exitCode; if (exitScheduled) return; exitScheduled = true; runShutdownOnce(shutdown, timeoutMs).then(() => { process.exit(resolvedExitCode); }); }; for (const signal of config.signals ?? DEFAULT_SIGNALS) { const listener = () => { shutdownAndExit(signalExitCode(signal), false); }; process.on(signal, listener); removeOwnedHandlers.push(() => { process.removeListener(signal, listener); }); } if (config.fatalErrors ?? true) { const uncaughtExceptionListener = (error) => { reportFatalError(error, "uncaughtException"); shutdownAndExit(1, true); }; const unhandledRejectionListener = (reason) => { reportFatalError(reason, "unhandledRejection"); shutdownAndExit(1, true); }; process.on("uncaughtException", uncaughtExceptionListener); process.on("unhandledRejection", unhandledRejectionListener); removeOwnedHandlers.push(() => { process.removeListener("uncaughtException", uncaughtExceptionListener); }, () => { process.removeListener("unhandledRejection", unhandledRejectionListener); }); } } /** * Flush telemetry when the process runs to completion. * * The signal and fatal-error handlers above cover a process that is stopped or * that crashes. Neither fires when a script simply finishes: the event loop * drains and Node exits, taking whatever the batch span processor was still * holding with it. `beforeExit` is the only hook for that case, and it is the * one that matters for CLIs, cron jobs, CI steps and serverless handlers. * * A flush, never a shutdown: `beforeExit` fires on *any* event-loop drain, not * only the final one, so tearing the SDK down here would silently kill * telemetry in a process that goes on to do more work. */ function installExitFlush(flushTelemetry, timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) { removeExitFlush?.(); exitFlushInFlight = void 0; const listener = (code) => { if (exitFlushInFlight || shutdownInFlight) return; const deadline = setTimeout(() => { process.exit(process.exitCode ?? code); }, timeoutMs); exitFlushInFlight = flushTelemetry().catch(() => void 0).finally(() => { clearTimeout(deadline); }); }; process.on("beforeExit", listener); removeExitFlush = () => { process.removeListener("beforeExit", listener); removeExitFlush = void 0; }; } function uninstallProcessHandlers() { for (const removeHandler of removeOwnedHandlers) removeHandler(); removeOwnedHandlers = []; removeExitFlush?.(); shutdownInFlight = void 0; exitFlushInFlight = void 0; } //#endregion //#region src/rate-limiter.ts /** * Token bucket rate limiter * * Allows bursts up to burstCapacity, then smooths to maxEventsPerSecond. * Thread-safe for async operations. */ var TokenBucketRateLimiter = class { tokens; maxTokens; refillRate; lastRefill; constructor(config) { this.maxTokens = config.burstCapacity || config.maxEventsPerSecond * 2; this.tokens = this.maxTokens; this.refillRate = config.maxEventsPerSecond / 1e3; this.lastRefill = Date.now(); } /** * Try to consume a token (allow an event) * Returns true if allowed, false if rate limit exceeded */ tryConsume(count = 1) { this.refill(); if (this.tokens >= count) { this.tokens -= count; return true; } return false; } /** * Wait until a token is available (async rate limiting) * Returns a promise that resolves when the event can be processed */ async waitForToken(count = 1) { this.refill(); if (this.tokens >= count) { this.tokens -= count; return; } const tokensNeeded = count - this.tokens; const waitMs = Math.ceil(tokensNeeded / this.refillRate); await new Promise((resolve) => setTimeout(resolve, waitMs)); return this.waitForToken(count); } /** * Refill tokens based on elapsed time */ refill() { const now = Date.now(); const tokensToAdd = (now - this.lastRefill) * this.refillRate; this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd); this.lastRefill = now; } /** * Get current available tokens (for testing/debugging) */ getAvailableTokens() { this.refill(); return Math.floor(this.tokens); } /** * Reset the rate limiter (for testing) */ reset() { this.tokens = this.maxTokens; this.lastRefill = Date.now(); } }; //#endregion //#region src/trace-context.ts /** * AsyncLocalStorage for storing the active context with baggage * This allows setters to update the context and have it persist */ const contextStorage = new node_async_hooks.AsyncLocalStorage(); /** * Get the context storage instance (for initialization in functional.ts) */ function getContextStorage() { return contextStorage; } /** * Get the active OTel context with the latest stored baggage overlaid. * Span identity always comes from the active OTel scope. */ function getActiveContextWithBaggage() { const activeContext = _opentelemetry_api.context.active(); const stored = contextStorage.getStore()?.value; if (!stored) return activeContext; const storedBaggage = _opentelemetry_api.propagation.getBaggage(stored); return storedBaggage ? _opentelemetry_api.propagation.setBaggage(activeContext, storedBaggage) : activeContext; } /** * Set a value in AsyncLocalStorage, preferring enterWith() when available * (Node.js) and falling back to run() for environments that only support * run() (e.g. Cloudflare Workers). * * On runtimes without enterWith() we mutate the existing run() scope when one * exists. This is what allows baggage/correlation updates to remain visible * for the rest of the traced callback in Workers. */ function enterOrRun(storage, value) { const existingStore = storage.getStore(); if (existingStore) { existingStore.value = value; return; } const boxedValue = { value }; try { storage.enterWith(boxedValue); } catch { storage.run(boxedValue, () => {}); } } function updateActiveContext(newContext) { enterOrRun(contextStorage, newContext); const manager = _opentelemetry_api.context._getContextManager?.(); if (!manager) return; const asyncLocal = manager._asyncLocalStorage ?? void 0; if (asyncLocal?.enterWith) { asyncLocal.enterWith(newContext); return; } if (typeof manager.with === "function") manager.with(newContext, () => {}); } /** * Create a TraceContext from an OpenTelemetry Span * * This utility extracts trace context information from a span * and provides span manipulation methods and baggage operations in a consistent format. * * Note: Baggage methods always operate on the currently active context, * which may differ from the context when createTraceContext was called. */ function createTraceContext(span) { const spanContext = span.spanContext(); if (!contextStorage.getStore()?.value) { const activeContext = _opentelemetry_api.context.active(); enterOrRun(contextStorage, activeContext); } const traceCtx = { traceId: spanContext.traceId, spanId: spanContext.spanId, correlationId: spanContext.traceId.slice(0, 16), setAttribute: span.setAttribute.bind(span), setAttributes: span.setAttributes.bind(span), setStatus: span.setStatus.bind(span), recordException: span.recordException.bind(span), addEvent: span.addEvent.bind(span), addLink: span.addLink.bind(span), addLinks: span.addLinks.bind(span), updateName: span.updateName.bind(span), isRecording: span.isRecording.bind(span), recordError: (error) => { const err = error instanceof Error ? error : new Error(String(error)); require_structured_error.recordStructuredError(traceCtx, err); }, track: (event, data) => { track(event, data); }, getBaggage(key) { const activeCtx = _opentelemetry_api.context.active(); let baggage = _opentelemetry_api.propagation.getBaggage(activeCtx); if (!baggage) { const storedContext = contextStorage.getStore()?.value; if (storedContext) baggage = _opentelemetry_api.propagation.getBaggage(storedContext); } return baggage?.getEntry(key)?.value; }, setBaggage(key, value) { const currentContext = getActiveContextWithBaggage(); const updated = (_opentelemetry_api.propagation.getBaggage(currentContext) ?? _opentelemetry_api.propagation.createBaggage()).setEntry(key, { value }); updateActiveContext(_opentelemetry_api.propagation.setBaggage(currentContext, updated)); return value; }, deleteBaggage(key) { const currentContext = getActiveContextWithBaggage(); const baggage = _opentelemetry_api.propagation.getBaggage(currentContext); if (baggage) { const updated = baggage.removeEntry(key); updateActiveContext(_opentelemetry_api.propagation.setBaggage(currentContext, updated)); } }, getAllBaggage() { const activeCtx = _opentelemetry_api.context.active(); let baggage = _opentelemetry_api.propagation.getBaggage(activeCtx); if (!baggage) { const storedContext = contextStorage.getStore()?.value; if (storedContext) baggage = _opentelemetry_api.propagation.getBaggage(storedContext); } if (!baggage) return /* @__PURE__ */ new Map(); const entries = /* @__PURE__ */ new Map(); for (const [key, entry] of baggage.getAllEntries()) entries.set(key, entry); return entries; }, getTypedBaggage: ((namespace) => { const activeCtx = _opentelemetry_api.context.active(); let baggage = _opentelemetry_api.propagation.getBaggage(activeCtx); if (!baggage) { const storedContext = contextStorage.getStore()?.value; if (storedContext) baggage = _opentelemetry_api.propagation.getBaggage(storedContext); } if (!baggage) return; const prefix = namespace ? `${namespace}.` : ""; const result = {}; for (const [key, entry] of baggage.getAllEntries()) if (namespace && key.startsWith(prefix)) { const fieldName = key.slice(prefix.length); result[fieldName] = entry.value; } else if (!namespace) result[key] = entry.value; return Object.keys(result).length > 0 ? result : void 0; }), setTypedBaggage: ((namespace, value) => { const currentContext = getActiveContextWithBaggage(); let baggage = _opentelemetry_api.propagation.getBaggage(currentContext) ?? _opentelemetry_api.propagation.createBaggage(); const prefix = namespace ? `${namespace}.` : ""; for (const [key, val] of Object.entries(value)) if (val !== void 0) { const baggageKey = `${prefix}${key}`; baggage = baggage.setEntry(baggageKey, { value: String(val) }); } updateActiveContext(_opentelemetry_api.propagation.setBaggage(currentContext, baggage)); }) }; return traceCtx; } /** * Define a typed baggage schema for type-safe baggage operations * * This helper provides a type-safe API for working with baggage entries. * The namespace parameter is optional and prefixes all keys to avoid collisions. * * @template T - The baggage schema type (all fields are treated as optional) * @param namespace - Optional namespace to prefix baggage keys * * @example Basic usage * ```typescript * type TenantBaggage = { tenantId: string; region?: string }; * const tenantBaggage = defineBaggageSchema<TenantBaggage>('tenant'); * * export const handler = trace<TenantBaggage>((ctx) => async () => { * // Get typed baggage * const tenant = tenantBaggage.get(ctx); * if (tenant?.tenantId) { * console.log('Tenant:', tenant.tenantId); * } * * // Set typed baggage * tenantBaggage.set(ctx, { tenantId: 't1', region: 'us-east-1' }); * }); * ``` * * @example With withBaggage helper * ```typescript * const tenantBaggage = defineBaggageSchema<TenantBaggage>('tenant'); * * export const handler = trace<TenantBaggage>((ctx) => async () => { * return await tenantBaggage.with(ctx, { tenantId: 't1' }, async () => { * // Baggage is available here and in child spans * const tenant = tenantBaggage.get(ctx); * }); * }); * ``` */ function defineBaggageSchema(namespace) { return { /** * Get typed baggage from context * @param ctx - Trace context * @returns Partial baggage object or undefined if no baggage is set */ get: (ctx) => { if (!ctx.getTypedBaggage) return void 0; return ctx.getTypedBaggage(namespace); }, /** * Set typed baggage in context * * Note: For proper scoping across async boundaries, use the `with` method instead * * @param ctx - Trace context * @param value - Partial baggage object to set */ set: (ctx, value) => { if (!ctx.setTypedBaggage) return; ctx.setTypedBaggage(namespace, value); }, /** * Run a function with typed baggage properly scoped * * This is the recommended way to set baggage as it ensures proper * scoping across async boundaries. * * @param ctx - Trace context (can be omitted, will use active context) * @param value - Partial baggage object to set * @param fn - Function to execute with the baggage */ with: (ctxOrValue, valueOrFn, maybeFn) => { const value = maybeFn ? valueOrFn : ctxOrValue; const fn = maybeFn || valueOrFn; const prefix = namespace ? `${namespace}.` : ""; const flatBaggage = {}; for (const [key, val] of Object.entries(value)) if (val !== void 0) flatBaggage[`${prefix}${key}`] = String(val); const currentContext = _opentelemetry_api.context.active(); let baggage = _opentelemetry_api.propagation.getBaggage(currentContext) ?? _opentelemetry_api.propagation.createBaggage(); for (const [key, val] of Object.entries(flatBaggage)) baggage = baggage.setEntry(key, { value: val }); const newContext = _opentelemetry_api.propagation.setBaggage(currentContext, baggage); return _opentelemetry_api.context.with(newContext, fn); } }; } //#endregion //#region src/correlation-id.ts /** * Correlation ID utilities for event-driven observability * * Provides a stable join key across events, logs, and spans even when traces fragment. * Format: 16 hex chars (64 bits), crypto-random, URL-safe. * * Lifecycle: * 1. Generated at boundary root (HTTP server span, message process span, cron job span) * 2. Reused within context (nested work shares it via AsyncLocalStorage) * 3. Propagated via baggage (optional, default OFF to avoid header bloat) * * @example Basic usage * ```typescript * import { generateCorrelationId, getCorrelationId } from 'autotel/correlation-id'; * * // Generate a new correlation ID * const id = generateCorrelationId(); * // Returns: 'a1b2c3d4e5f67890' * * // Get current correlation ID from context * const currentId = getCorrelationId(); * ``` */ /** * AsyncLocalStorage for storing correlation ID * This allows correlation IDs to persist across async boundaries */ const correlationStorage = new node_async_hooks.AsyncLocalStorage(); /** * Baggage key for correlation ID propagation */ const CORRELATION_ID_BAGGAGE_KEY = "autotel.correlation_id"; /** * Generate a new correlation ID * * Format: 16 hex chars (64 bits), crypto-random, URL-safe * * @returns A new correlation ID * * @example * ```typescript * const id = generateCorrelationId(); * // Returns: 'a1b2c3d4e5f67890' * ``` */ function generateCorrelationId() { const bytes = /* @__PURE__ */ new Uint8Array(8); crypto.getRandomValues(bytes); return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); } /** * Get the current correlation ID from context * * Resolution order: * 1. AsyncLocalStorage (from explicit setCorrelationId or runWithCorrelationId) * 2. Baggage (if propagated from upstream) * 3. Active span's trace ID (first 16 chars as fallback) * 4. undefined (if not in any context) * * @returns Current correlation ID or undefined * * @example * ```typescript * const id = getCorrelationId(); * if (id) { * console.log('Correlation ID:', id); * } * ``` */ function getCorrelationId() { const storedId = correlationStorage.getStore()?.value; if (storedId) return storedId; const activeContext = _opentelemetry_api.context.active(); const baggageEntry = _opentelemetry_api.propagation.getBaggage(activeContext)?.getEntry(CORRELATION_ID_BAGGAGE_KEY); if (baggageEntry?.value) return baggageEntry.value; const span = _opentelemetry_api.trace.getActiveSpan(); if (span) return span.spanContext().traceId.slice(0, 16); } /** * Get or create a correlation ID * * If a correlation ID exists in the current context, returns it. * Otherwise, generates a new one. * * @returns Existing or new correlation ID * * @example * ```typescript * const id = getOrCreateCorrelationId(); * // Always returns a valid correlation ID * ``` */ function getOrCreateCorrelationId() { return getCorrelationId() ?? generateCorrelationId(); } /** * Run a function with a specific correlation ID in context * * The correlation ID will be available via getCorrelationId() throughout * the execution of the function and any async operations it spawns. * * @param correlationId - Correlation ID to use * @param fn - Function to execute * @returns The return value of the function * * @example * ```typescript * await runWithCorrelationId('abc123', async () => { * // getCorrelationId() returns 'abc123' here * await processRequest(); * }); * ``` */ function runWithCorrelationId(correlationId, fn) { return correlationStorage.run({ value: correlationId }, fn); } /** * Set correlation ID in the current context (mutates context) * * Note: This updates the AsyncLocalStorage context. For proper scoping * across async boundaries, prefer runWithCorrelationId() instead. * * @param correlationId - Correlation ID to set * * @example * ```typescript * setCorrelationId('abc123'); * // Now getCorrelationId() returns 'abc123' * ``` */ function setCorrelationId(correlationId) { enterOrRun(correlationStorage, correlationId); } /** * Set correlation ID in baggage for propagation * * This adds the correlation ID to the W3C baggage header, allowing it * to be propagated to downstream services. * * Note: Only use this when you explicitly want cross-service propagation. * Default is OFF to avoid header bloat. * * @param correlationId - Correlation ID to propagate * @returns New context with baggage set * * @example * ```typescript * const newContext = setCorrelationIdInBaggage('abc123'); * context.with(newContext, () => { * // Baggage will be propagated in outgoing requests * }); * ``` */ function setCorrelationIdInBaggage(correlationId) { const activeContext = _opentelemetry_api.context.active(); let baggage = _opentelemetry_api.propagation.getBaggage(activeContext) ?? _opentelemetry_api.propagation.createBaggage(); baggage = baggage.setEntry(CORRELATION_ID_BAGGAGE_KEY, { value: correlationId }); return _opentelemetry_api.propagation.setBaggage(activeContext, baggage); } /** * Get the correlation storage instance (for internal use in init/shutdown) */ function getCorrelationStorage() { return correlationStorage; } //#endregion //#region src/event-queue.ts const DEFAULT_CONFIG$2 = { maxSize: 5e4, batchSize: 100, flushInterval: 1e4, maxRetries: 3, rateLimit: { maxEventsPerSecond: 100, burstCapacity: 200 } }; /** * Get subscriber name for metrics (stable, low-cardinality) * * Priority: * 1. Explicit config: subscriber.name * 2. Class static property (if available) * 3. Fallback: lowercase class name without "Subscriber" suffix */ function getSubscriberName(subscriber) { if (subscriber.name) return subscriber.name.toLowerCase(); return (subscriber.constructor?.name || "unknown").replace(/Subscriber$/i, "").toLowerCase(); } /** * Subscribers whose `shutdown()` has run. Terminal for the client they wrap, so * a queue rebuilt from the same config must not reuse them. */ const shutDownSubscribers = /* @__PURE__ */ new WeakSet(); /** * Events queue with batching and backpressure * * Features: * - Batches events for efficient sending * - Bounded queue with drop-oldest policy (prod) or blocking (dev) * - Exponential backoff retry * - Rate limiting to prevent overwhelming subscribers * - Graceful flush on shutdown */ var EventQueue = class { queue = []; flushTimer = null; config; subscribers; rateLimiter; flushPromise = null; isShuttingDown = false; metrics = null; observableCleanups = []; subscriberHealthy = /* @__PURE__ */ new Map(); constructor(subscribers, config) { const live = subscribers.filter((s) => !shutDownSubscribers.has(s)); 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()"); this.subscribers = live; this.config = { ...DEFAULT_CONFIG$2, ...config }; this.rateLimiter = this.config.rateLimit ? new TokenBucketRateLimiter(this.config.rateLimit) : null; for (const subscriber of this.subscribers) { const name = getSubscriberName(subscriber); this.subscriberHealthy.set(name, true); } this.initMetrics(); } /** * Initialize OTel metrics for queue observability */ initMetrics() { const meter = require_config.getConfig().meter; const queueSize = meter.createObservableGauge("autotel.event_delivery.queue.size", { description: "Current number of events in the delivery queue", unit: "count" }); const queueSizeCallback = (observableResult) => { observableResult.observe(this.queue.length); }; queueSize.addCallback(queueSizeCallback); this.observableCleanups.push(() => queueSize.removeCallback(queueSizeCallback)); const oldestAge = meter.createObservableGauge("autotel.event_delivery.queue.oldest_age_ms", { description: "Age of the oldest event in the queue in milliseconds", unit: "ms" }); const oldestAgeCallback = (observableResult) => { if (this.queue.length > 0) { const oldest = this.queue[0]; const ageMs = Date.now() - oldest.timestamp; observableResult.observe(ageMs); } else observableResult.observe(0); }; oldestAge.addCallback(oldestAgeCallback); this.observableCleanups.push(() => oldestAge.removeCallback(oldestAgeCallback)); const delivered = meter.createCounter("autotel.event_delivery.queue.delivered", { description: "Number of events successfully delivered to subscribers", unit: "count" }); const failed = meter.createCounter("autotel.event_delivery.queue.failed", { description: "Number of events that failed delivery after all retry attempts", unit: "count" }); const dropped = meter.createCounter("autotel.event_delivery.queue.dropped", { description: "Number of events dropped from the queue", unit: "count" }); const latency = meter.createHistogram("autotel.event_delivery.queue.latency_ms", { description: "Event delivery latency from enqueue to successful send", unit: "ms" }); const subscriberHealth = meter.createObservableGauge("autotel.event_delivery.subscriber.health", { description: "Subscriber health status (1=healthy, 0=unhealthy)", unit: "1" }); const subscriberHealthCallback = (observableResult) => { for (const [subscriberName, isHealthy] of this.subscriberHealthy) observableResult.observe(isHealthy ? 1 : 0, { subscriber: subscriberName }); }; subscriberHealth.addCallback(subscriberHealthCallback); this.observableCleanups.push(() => subscriberHealth.removeCallback(subscriberHealthCallback)); this.metrics = { queueSize, oldestAge, delivered, failed, dropped, latency, subscriberHealth }; } /** * Record a dropped event with reason and emit debug breadcrumb */ recordDropped(reason, event, subscriberName) { const attrs = { reason }; if (subscriberName) attrs.subscriber = subscriberName; this.metrics?.dropped.add(1, attrs); const logLevel = reason === "payload_invalid" ? "error" : "warn"; const logger = getLogger(); if (logLevel === "error") logger.error({ eventName: event?.name, subscriber: subscriberName, reason, correlationId: event?._correlationId, traceId: event?._traceId }, `[autotel] Event dropped: ${reason}`); else logger.warn({ eventName: event?.name, subscriber: subscriberName, reason, correlationId: event?._correlationId, traceId: event?._traceId }, `[autotel] Event dropped: ${reason}`); } /** * Record permanent delivery failure (after all retries exhausted) * Increments failed counter and logs error */ recordFailed(event, subscriberName, error) { this.metrics?.failed.add(1, { subscriber: subscriberName }); this.subscriberHealthy.set(subscriberName, false); getLogger().error({ eventName: event.name, subscriber: subscriberName, correlationId: event._correlationId, traceId: event._traceId, err: error }, `[autotel] Event delivery failed after all retries`); } /** * Mark subscriber as unhealthy on transient failure (without incrementing failed counter) * Used during retry attempts - only recordFailed should increment the counter */ markSubscriberUnhealthy(subscriberName) { this.subscriberHealthy.set(subscriberName, false); } /** * Record successful delivery */ recordDelivered(event, subscriberName, startTime) { const latencyMs = Date.now() - startTime; this.metrics?.delivered.add(1, { subscriber: subscriberName }); this.metrics?.latency.record(latencyMs, { subscriber: subscriberName }); this.subscriberHealthy.set(subscriberName, true); } /** * Enqueue an event for sending * * Backpressure policy: * - Drops oldest event and logs warning if queue is full (same behavior in all environments) */ enqueue(event) { if (this.isShuttingDown) { this.recordDropped("shutdown", event); return; } if (this.queue.length >= this.config.maxSize) { const droppedEvent = this.queue.shift(); this.recordDropped("rate_limit", droppedEvent); 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.`); } const enrichedEvent = { ...event, _correlationId: event._correlationId || getOrCreateCorrelationId() }; this.queue.push(enrichedEvent); this.scheduleBatchFlush(); } /** * Schedule a batch flush if not already scheduled */ scheduleBatchFlush() { if (this.flushTimer || this.flushPromise) return; this.flushTimer = setTimeout(() => { this.flushTimer = null; this.flushBatch(); }, this.config.flushInterval); } /** * Flush a batch of events * Uses promise-based concurrency control to prevent race conditions */ async flushBatch() { if (this.queue.length === 0) return; if (this.flushPromise) { await this.flushPromise; return; } this.flushPromise = this.doFlushBatch(); try { await this.flushPromise; } finally { this.flushPromise = null; if (this.queue.length > 0) this.scheduleBatchFlush(); } } /** * Internal flush implementation */ async doFlushBatch() { const batch = this.queue.splice(0, this.config.batchSize); await this.sendWithRetry(batch, this.config.maxRetries); } /** * Send events with exponential backoff retry * Tracks per-event, per-subscriber failures so failed counter reflects actual failed deliveries. * On retry, only failed (event, subscriber) pairs are re-sent to avoid double-counting delivered. */ async sendWithRetry(events, retriesLeft, subscribersByEventIndex) { const failedDeliveries = await this.sendToSubscribers(events, subscribersByEventIndex); if (failedDeliveries.length > 0) if (retriesLeft > 0) { const failedEventIndicesOrdered = [...new Set(failedDeliveries.map((f) => f.eventIndex))].toSorted((a, b) => a - b); const eventsToRetry = failedEventIndicesOrdered.map((i) => events[i]); const failedSubscribersByRetryIndex = /* @__PURE__ */ new Map(); for (const [j, origIndex] of failedEventIndicesOrdered.entries()) { const set = /* @__PURE__ */ new Set(); for (const { eventIndex, subscriberName } of failedDeliveries) if (eventIndex === origIndex) set.add(subscriberName); failedSubscribersByRetryIndex.set(j, set); } const delay = Math.pow(2, this.config.maxRetries - retriesLeft) * 1e3; await new Promise((resolve) => setTimeout(resolve, delay)); return this.sendWithRetry(eventsToRetry, retriesLeft - 1, failedSubscribersByRetryIndex); } else { for (const { eventIndex, subscriberName, error } of failedDeliveries) { const event = events[eventIndex]; if (event) this.recordFailed(event, subscriberName, error); } const failedSubscriberNames = [...new Set(failedDeliveries.map((f) => f.subscriberName))]; getLogger().error({ failedSubscribers: failedSubscriberNames, retriesAttempted: this.config.maxRetries }, "[autotel] Failed to send events after retries"); } } /** * Send events to configured subscribers with rate limiting and metrics. * When subscribersByEventIndex is provided (retry path), only those subscribers are tried per event. * Returns per-event, per-subscriber failures (empty if all succeeded). */ async sendToSubscribers(events, subscribersByEventIndex) { const failedDeliveries = []; const sendOne = async (event, eventIndex) => { const subscriberNames = subscribersByEventIndex?.get(eventIndex); const failures = await this.sendEventToSubscribers(event, subscriberNames ?? void 0); for (const failure of failures) failedDeliveries.push({ eventIndex, subscriberName: failure.subscriberName, error: failure.error }); }; if (!this.rateLimiter) { for (const [i, event] of events.entries()) if (event) await sendOne(event, i); return failedDeliveries; } for (const [i, event] of events.entries()) { await this.rateLimiter.waitForToken(); if (event) await sendOne(event, i); } return failedDeliveries; } /** * Send a single event to subscribers. * - When subscriberNames is undefined (initial attempt): send to all subscribers. * - When subscriberNames is provided (retry): send only to those subscribers (never re-send to healthy ones). * Returns list of subscribers that failed (empty if all succeeded). */ async sendEventToSubscribers(event, subscriberNames) { const startTime = event.timestamp; const failures = []; const subscribersToTry = subscriberNames === void 0 ? this.subscribers : this.subscribers.filter((s) => subscriberNames.has(getSubscriberName(s))); const results = await Promise.allSettled(subscribersToTry.map(async (subscriber) => { const subscriberName = getSubscriberName(subscriber); try { await subscriber.trackEvent(event.name, event.attributes, { autotel: event.autotel, schema: event.schema }); this.recordDelivered(event, subscriberName, startTime); return { subscriberName, success: true }; } catch (error) { this.markSubscriberUnhealthy(subscriberName); return { subscriberName, success: false, error: error instanceof Error ? error : void 0 }; } })); for (const result of results) if (result.status === "fulfilled" && !result.value.success) failures.push({ subscriberName: result.value.subscriberName, error: result.value.error }); return failures; } /** * Flush all remaining events. Queue remains usable after flush (e.g. for * auto-flush at root span end). Use shutdown() when tearing down the queue. */ async flush() { if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } if (this.flushPromise) await this.flushPromise; while (this.queue.length > 0) await this.doFlushBatch(); } /** * Flush remaining events and permanently disable the queue (reject new events). * Use for process/SDK shutdown; use flush() for periodic or span-end drain. */ async shutdown() { this.isShuttingDown = true; await this.flush(); const drains = await Promise.allSettled(this.subscribers.map(async (subscriber) => { if (!subscriber.shutdown) return; shutDownSubscribers.add(subscriber); return subscriber.shutdown(); })); for (const [index, drain] of drains.entries()) { if (drain.status !== "rejected") continue; const subscriberName = getSubscriberName(this.subscribers[index]); this.subscriberHealthy.set(subscriberName, false); getLogger().error({ subscriber: subscriberName, err: drain.reason }, "[autotel] Subscriber shutdown failed, buffered events may be lost"); } } /** * Cleanup observable metric callbacks to prevent memory leaks * Call this when destroying the EventQueue instance */ cleanup() { for (const cleanupFn of this.observableCleanups) try { cleanupFn(); } catch {} this.observableCleanups = []; } /** * Get queue size (for testing/debugging) */ size() { return this.queue.length; } /** * Get subscriber health status (for testing/debugging) */ getSubscriberHealth() { return new Map(this.subscriberHealthy); } /** * Check if a specific subscriber is healthy */ isSubscriberHealthy(subscriberName) { return this.subscriberHealthy.get(subscriberName.toLowerCase()) ?? true; } /** * Manually mark a subscriber as healthy or unhealthy * (used for circuit breaker integration) */ setSubscriberHealth(subscriberName, healthy) { this.subscriberHealthy.set(subscriberName.toLowerCase(), healthy); } }; //#endregion //#region src/validation.ts const DEFAULT_CONFIG$1 = { maxEventNameLength: 100, maxAttributeKeyLength: 100, maxAttributeValueLength: 1e3, maxAttributeCount: 50, maxNestingDepth: 3, sensitivePatterns: [ /password/i, /secret/i, /token/i, /api[_-]?key/i, /access[_-]?key/i, /private[_-]?key/i, /auth/i, /credential/i, /ssn/i, /credit[_-]?card/i ] }; var ValidationError = class extends Error { constructor(message) { super(message); this.name = "ValidationError"; } }; /** * Validate and sanitize event name * Throws ValidationError if invalid */ function validateEventName(eventName, config = DEFAULT_CONFIG$1) { if (typeof eventName !== "string") throw new ValidationError(`Event name must be a string, got ${typeof eventName}`); const trimmed = eventName.trim(); if (trimmed.length === 0) throw new ValidationError("Event name cannot be empty"); if (trimmed.length > config.maxEventNameLength) throw new ValidationError(`Event name too lon