UNPKG

autotel

Version:
1,214 lines (1,207 loc) 39.6 kB
const require_chunk = require('./chunk-C_NdSu1c.cjs'); const require_init = require('./init-BXiuPK6j.cjs'); const require_config = require('./config.cjs'); const require_structured_error = require('./structured-error-CHg7DoIQ.cjs'); let _opentelemetry_api = require("@opentelemetry/api"); let node_async_hooks = require("node:async_hooks"); node_async_hooks = require_chunk.__toESM(node_async_hooks, 1); //#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 context, checking our stored context first * This ensures baggage setters work with OpenTelemetry's propagation */ function getActiveContextWithBaggage() { return contextStorage.getStore()?.value ?? _opentelemetry_api.context.active(); } /** * 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) enterOrRun(contextStorage, _opentelemetry_api.context.active()); 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) => { require_structured_error.recordStructuredError(traceCtx, error instanceof Error ? error : new Error(String(error))); }, 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 activeCtx = _opentelemetry_api.context.active(); const currentContext = contextStorage.getStore()?.value ?? activeCtx; 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 activeCtx = _opentelemetry_api.context.active(); const currentContext = contextStorage.getStore()?.value ?? activeCtx; 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 activeCtx = _opentelemetry_api.context.active(); const currentContext = contextStorage.getStore()?.value ?? activeCtx; 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 = 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$1 = { 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(); } /** * 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) { this.subscribers = subscribers; this.config = { ...DEFAULT_CONFIG$1, ...config }; this.rateLimiter = this.config.rateLimit ? new TokenBucketRateLimiter(this.config.rateLimit) : null; for (const subscriber of 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 = require_init.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); require_init.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); require_init.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))].sort((a, b) => a - b); const eventsToRetry = failedEventIndicesOrdered.map((i) => events[i]); const failedSubscribersByRetryIndex = /* @__PURE__ */ new Map(); for (let j = 0; j < failedEventIndicesOrdered.length; j++) { const origIndex = failedEventIndicesOrdered[j]; 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))]; require_init.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 (let i = 0; i < events.length; i++) { const event = events[i]; if (event) await sendOne(event, i); } return failedDeliveries; } for (let i = 0; i < events.length; i++) { await this.rateLimiter.waitForToken(); const event = events[i]; 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(); } /** * 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 = { 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) { 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 long (${trimmed.length} chars). Max: ${config.maxEventNameLength}`); if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) throw new ValidationError(`Event name contains invalid characters: "${trimmed}". Use only letters, numbers, dots, underscores, and hyphens.`); return trimmed; } /** * Validate and sanitize attributes * Returns sanitized attributes (sensitive data redacted) */ function validateAttributes(attributes, config = DEFAULT_CONFIG) { if (attributes === void 0 || attributes === null) return; if (typeof attributes !== "object" || Array.isArray(attributes)) throw new ValidationError("Attributes must be an object"); const keys = Object.keys(attributes); if (keys.length > config.maxAttributeCount) throw new ValidationError(`Too many attributes (${keys.length}). Max: ${config.maxAttributeCount}`); const sanitized = {}; for (const key of keys) { if (key.length > config.maxAttributeKeyLength) throw new ValidationError(`Attribute key too long: "${key.slice(0, 20)}..." (${key.length} chars). Max: ${config.maxAttributeKeyLength}`); const value = attributes[key]; if (typeof value === "string" && config.sensitivePatterns.some((pattern) => pattern.test(key))) { sanitized[key] = "[REDACTED]"; continue; } sanitized[key] = sanitizeValue(value, config, 1); } return sanitized; } /** * Sanitize attribute value (recursive) */ function sanitizeValue(value, config, depth) { if (depth > config.maxNestingDepth) return "[MAX_DEPTH_EXCEEDED]"; if (value === null || value === void 0) return value; if (typeof value === "string") { if (value.length > config.maxAttributeValueLength) return value.slice(0, config.maxAttributeValueLength) + "..."; return value; } if (typeof value === "number" || typeof value === "boolean") return value; if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, config, depth + 1)); if (typeof value === "object") try { JSON.stringify(value); const sanitized = {}; for (const key in value) if (Object.prototype.hasOwnProperty.call(value, key)) { const nested = value[key]; sanitized[key] = typeof nested === "string" && config.sensitivePatterns.some((pattern) => pattern.test(key)) ? "[REDACTED]" : sanitizeValue(nested, config, depth + 1); } return sanitized; } catch { return "[CIRCULAR]"; } return `[${typeof value}]`; } /** * Validate and sanitize an events event * Returns { eventName, attributes } with sanitized values */ function validateEvent(eventName, attributes, config) { const fullConfig = { ...DEFAULT_CONFIG, ...config }; return { eventName: validateEventName(eventName, fullConfig), attributes: validateAttributes(attributes, fullConfig) }; } //#endregion //#region src/track.ts /** * Global track() function for business events * * Simple, no instantiation needed, auto-attaches trace context */ let eventsQueue = null; /** * Build autotel event context for trace correlation * * Works in multiple contexts: * 1. Inside a span → use current span's trace_id + span_id * 2. Outside span → use correlation_id only * 3. With trace URL config → include clickable trace URL */ function buildAutotelContext(span) { const eventsConfig = require_init.getEventsConfig(); const config = require_init.getConfig(); const correlationId = getOrCreateCorrelationId(); if (!eventsConfig?.includeTraceContext) return { correlation_id: correlationId }; const autotelContext = { correlation_id: correlationId }; const spanContext = span?.spanContext(); if (spanContext) { autotelContext.trace_id = spanContext.traceId; autotelContext.span_id = spanContext.spanId; autotelContext.trace_flags = spanContext.traceFlags.toString(16).padStart(2, "0"); const traceState = spanContext.traceState; if (traceState) try { if (typeof traceState.serialize === "function") { const traceStateStr = traceState.serialize(); if (traceStateStr) autotelContext.trace_state = traceStateStr; } } catch {} if (eventsConfig.traceUrl && config) { const traceUrl = eventsConfig.traceUrl({ traceId: spanContext.traceId, spanId: spanContext.spanId, correlationId, serviceName: config.service, environment: config.environment }); if (traceUrl) autotelContext.trace_url = traceUrl; } } else if (eventsConfig.traceUrl && config) { const traceUrl = eventsConfig.traceUrl({ correlationId, serviceName: config.service, environment: config.environment }); if (traceUrl) autotelContext.trace_url = traceUrl; } return autotelContext; } /** * Initialize events queue lazily */ function getOrCreateQueue() { if (!require_init.isInitialized()) { require_init.warnIfNotInitialized("track()"); return null; } if (!eventsQueue) { const config = require_init.getConfig(); if (!config?.subscribers || config.subscribers.length === 0) return null; eventsQueue = new EventQueue(config.subscribers); } return eventsQueue; } /** * Track a business events event * * Features: * - Auto-attaches traceId and spanId if in active span * - Batched sending with retry * - Type-safe with optional generic * - No-op if init() not called or no subscribers configured * * @example Basic usage * ```typescript * track('user.signup', { userId: '123', plan: 'pro' }) * ``` * * @example With type safety * ```typescript * interface EventDatas { * 'user.signup': { userId: string; plan: string } * 'plan.upgraded': { userId: string; revenue: number } * } * * track<EventDatas>('user.signup', { userId: '123', plan: 'pro' }) * ``` * * @example Trace correlation (automatic) * ```typescript * @Instrumented() * class UserService { * async createUser(data: CreateUserData) { * // This track call automatically includes traceId + spanId * track('user.signup', { userId: data.id }) * } * } * ``` */ function track(event, data, options) { const queue = getOrCreateQueue(); if (!queue) return; const validated = validateEvent(event, data, require_init.getValidationConfig() || void 0); const span = _opentelemetry_api.trace.getActiveSpan(); const enrichedData = span ? { ...validated.attributes, traceId: span.spanContext().traceId, spanId: span.spanContext().spanId } : validated.attributes; const autotelContext = buildAutotelContext(span); queue.enqueue({ name: validated.eventName, attributes: enrichedData, timestamp: Date.now(), autotel: autotelContext, schema: options?.schema }); } /** * Get events queue (for flush/shutdown) * @internal */ function getEventQueue() { return eventsQueue; } /** * Reset events queue (for shutdown/cleanup) * @internal */ function resetEventQueue() { eventsQueue = null; } //#endregion Object.defineProperty(exports, 'CORRELATION_ID_BAGGAGE_KEY', { enumerable: true, get: function () { return CORRELATION_ID_BAGGAGE_KEY; } }); Object.defineProperty(exports, 'createTraceContext', { enumerable: true, get: function () { return createTraceContext; } }); Object.defineProperty(exports, 'defineBaggageSchema', { enumerable: true, get: function () { return defineBaggageSchema; } }); Object.defineProperty(exports, 'enterOrRun', { enumerable: true, get: function () { return enterOrRun; } }); Object.defineProperty(exports, 'generateCorrelationId', { enumerable: true, get: function () { return generateCorrelationId; } }); Object.defineProperty(exports, 'getActiveContextWithBaggage', { enumerable: true, get: function () { return getActiveContextWithBaggage; } }); Object.defineProperty(exports, 'getContextStorage', { enumerable: true, get: function () { return getContextStorage; } }); Object.defineProperty(exports, 'getCorrelationId', { enumerable: true, get: function () { return getCorrelationId; } }); Object.defineProperty(exports, 'getCorrelationStorage', { enumerable: true, get: function () { return getCorrelationStorage; } }); Object.defineProperty(exports, 'getEventQueue', { enumerable: true, get: function () { return getEventQueue; } }); Object.defineProperty(exports, 'getOrCreateCorrelationId', { enumerable: true, get: function () { return getOrCreateCorrelationId; } }); Object.defineProperty(exports, 'resetEventQueue', { enumerable: true, get: function () { return resetEventQueue; } }); Object.defineProperty(exports, 'runWithCorrelationId', { enumerable: true, get: function () { return runWithCorrelationId; } }); Object.defineProperty(exports, 'setCorrelationId', { enumerable: true, get: function () { return setCorrelationId; } }); Object.defineProperty(exports, 'setCorrelationIdInBaggage', { enumerable: true, get: function () { return setCorrelationIdInBaggage; } }); Object.defineProperty(exports, 'track', { enumerable: true, get: function () { return track; } }); Object.defineProperty(exports, 'validateEvent', { enumerable: true, get: function () { return validateEvent; } }); //# sourceMappingURL=track-Cb3Q4QmS.cjs.map