UNPKG

autotel

Version:
1,111 lines (1,104 loc) 36.9 kB
import { getConfig } from "./config.js"; import { c as isInitialized, d as warnIfNotInitialized, i as getLogger, n as getConfig$1, o as getValidationConfig, r as getEventsConfig } from "./init-BS2JVkrL.js"; import { r as recordStructuredError } from "./structured-error-9--cxBay.js"; import { context, propagation, trace } from "@opentelemetry/api"; import * as nodeAsyncHooks from "node:async_hooks"; //#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 nodeAsyncHooks.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 ?? 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 = 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, 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) => { recordStructuredError(traceCtx, error instanceof Error ? error : new Error(String(error))); }, track: (event, data) => { track(event, data); }, getBaggage(key) { const activeCtx = context.active(); let baggage = propagation.getBaggage(activeCtx); if (!baggage) { const storedContext = contextStorage.getStore()?.value; if (storedContext) baggage = propagation.getBaggage(storedContext); } return baggage?.getEntry(key)?.value; }, setBaggage(key, value) { const activeCtx = context.active(); const currentContext = contextStorage.getStore()?.value ?? activeCtx; const updated = (propagation.getBaggage(currentContext) ?? propagation.createBaggage()).setEntry(key, { value }); updateActiveContext(propagation.setBaggage(currentContext, updated)); return value; }, deleteBaggage(key) { const activeCtx = context.active(); const currentContext = contextStorage.getStore()?.value ?? activeCtx; const baggage = propagation.getBaggage(currentContext); if (baggage) { const updated = baggage.removeEntry(key); updateActiveContext(propagation.setBaggage(currentContext, updated)); } }, getAllBaggage() { const activeCtx = context.active(); let baggage = propagation.getBaggage(activeCtx); if (!baggage) { const storedContext = contextStorage.getStore()?.value; if (storedContext) baggage = 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 = context.active(); let baggage = propagation.getBaggage(activeCtx); if (!baggage) { const storedContext = contextStorage.getStore()?.value; if (storedContext) baggage = 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 = context.active(); const currentContext = contextStorage.getStore()?.value ?? activeCtx; let baggage = propagation.getBaggage(currentContext) ?? 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(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 = context.active(); let baggage = propagation.getBaggage(currentContext) ?? propagation.createBaggage(); for (const [key, val] of Object.entries(flatBaggage)) baggage = baggage.setEntry(key, { value: val }); const newContext = propagation.setBaggage(currentContext, baggage); return 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 nodeAsyncHooks.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 = context.active(); const baggageEntry = propagation.getBaggage(activeContext)?.getEntry(CORRELATION_ID_BAGGAGE_KEY); if (baggageEntry?.value) return baggageEntry.value; const span = 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 = context.active(); let baggage = propagation.getBaggage(activeContext) ?? propagation.createBaggage(); baggage = baggage.setEntry(CORRELATION_ID_BAGGAGE_KEY, { value: correlationId }); return 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 = 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))].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))]; 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 = getEventsConfig(); const config = getConfig$1(); 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 (!isInitialized()) { warnIfNotInitialized("track()"); return null; } if (!eventsQueue) { const config = getConfig$1(); 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, getValidationConfig() || void 0); const span = 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 export { getContextStorage as _, CORRELATION_ID_BAGGAGE_KEY as a, getCorrelationStorage as c, setCorrelationId as d, setCorrelationIdInBaggage as f, getActiveContextWithBaggage as g, enterOrRun as h, validateEvent as i, getOrCreateCorrelationId as l, defineBaggageSchema as m, resetEventQueue as n, generateCorrelationId as o, createTraceContext as p, track as r, getCorrelationId as s, getEventQueue as t, runWithCorrelationId as u }; //# sourceMappingURL=track-COUuU48p.js.map