UNPKG

autotel

Version:
833 lines (829 loc) 24.9 kB
import { getOperationContext, validateEvent } from './chunk-WD4RP6IV.js'; import { getOrCreateCorrelationId } from './chunk-S4OFEXLA.js'; import { getConfig, getEventsConfig, getValidationConfig, getLogger } from './chunk-W35FVJBC.js'; import { trace, context, propagation } from '@opentelemetry/api'; // src/circuit-breaker.ts var DEFAULT_CONFIG = { failureThreshold: 5, resetTimeout: 3e4, // 30 seconds windowSize: 6e4 // 1 minute }; var CircuitState = { CLOSED: "CLOSED", // Normal operation OPEN: "OPEN", // Fast-fail mode HALF_OPEN: "HALF_OPEN" // Testing recovery }; var CircuitBreaker = class { state = CircuitState.CLOSED; failures = []; lastFailureTime = 0; config; name; constructor(name, config) { this.name = name; this.config = { ...DEFAULT_CONFIG, ...config }; } /** * Execute a function with circuit breaker protection * Throws CircuitOpenError if circuit is open */ async execute(fn) { if (this.state === CircuitState.OPEN) { const now = Date.now(); if (now - this.lastFailureTime >= this.config.resetTimeout) { this.state = CircuitState.HALF_OPEN; } else { throw new CircuitOpenError( `Circuit breaker is OPEN for ${this.name}. Will retry in ${Math.ceil((this.config.resetTimeout - (now - this.lastFailureTime)) / 1e3)}s` ); } } try { const result = await fn(); if (this.state === CircuitState.HALF_OPEN) { this.reset(); } return result; } catch (error) { this.recordFailure(error); throw error; } } /** * Record a failure and potentially open the circuit */ recordFailure(error) { const now = Date.now(); this.failures = this.failures.filter( (f) => now - f.timestamp < this.config.windowSize ); this.failures.push({ timestamp: now, error: error instanceof Error ? error.message : String(error) }); this.lastFailureTime = now; if (this.failures.length >= this.config.failureThreshold) { if (this.state === CircuitState.HALF_OPEN) { this.state = CircuitState.OPEN; } else if (this.state === CircuitState.CLOSED) { this.state = CircuitState.OPEN; } } } /** * Reset the circuit breaker (on success) */ reset() { this.state = CircuitState.CLOSED; this.failures = []; this.lastFailureTime = 0; } /** * Get current state (for monitoring) */ getState() { return this.state; } /** * Get failure count in current window */ getFailureCount() { const now = Date.now(); this.failures = this.failures.filter( (f) => now - f.timestamp < this.config.windowSize ); return this.failures.length; } /** * Get recent failures (for debugging) */ getRecentFailures() { const now = Date.now(); return this.failures.filter( (f) => now - f.timestamp < this.config.windowSize ); } /** * Manually reset the circuit breaker (for testing or manual intervention) */ forceReset() { this.reset(); } /** * Manually open the circuit (for testing or manual intervention) */ forceOpen() { this.state = CircuitState.OPEN; this.lastFailureTime = Date.now(); } }; var CircuitOpenError = class extends Error { constructor(message) { super(message); this.name = "CircuitOpenError"; } }; // src/events-config.ts function hashValue(value) { let hash = 0; for (let i = 0; i < value.length; i++) { const char = value.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return (hash >>> 0).toString(16).padStart(8, "0"); } // src/event.ts var Event = class { serviceName; logger; collector; subscribers; hasSubscribers; // Cached for performance circuitBreakers; // One per subscriber /** * Create a new Event instance * * **Note**: Most users should use `init()` + `track()` instead of creating Event instances directly. * * **Subscriber Resolution**: * - If `subscribers` provided in options → uses those (instance override) * - If `subscribers` not provided → falls back to subscribers from `init()` (global config) * - If neither → no subscribers (events logged only) * * @param serviceName - Service name for identifying events * @param options - Optional configuration (logger, collector, subscribers) * * @example Recommended: Use track() with init() * ```typescript * import { init, track } from 'autotel'; * import { PostHogSubscriber } from 'autotel-subscribers/posthog'; * * init({ * service: 'checkout', * subscribers: [new PostHogSubscriber({ apiKey: 'phc_...' })] * }); * * track('purchase.completed', { amount: 99.99 }); * ``` * * @example Inherit subscribers from init() * ```typescript * // Uses subscribers configured in init() * const event = new Event('checkout'); * event.trackEvent('purchase.completed', { amount: 99.99 }); * ``` * * @example Override subscribers for this instance * ```typescript * import { Event } from 'autotel/event'; * import { PostHogSubscriber } from 'autotel-subscribers/posthog'; * * // Override: use different subscribers for this instance only * const event = new Event('checkout', { * subscribers: [new PostHogSubscriber({ apiKey: 'phc_different_project' })] * }); * ``` */ constructor(serviceName, options = {}) { this.serviceName = serviceName; this.logger = options.logger; this.collector = options.collector; this.subscribers = options.subscribers === void 0 ? getConfig()?.subscribers || [] : options.subscribers; this.hasSubscribers = this.subscribers.length > 0; this.circuitBreakers = /* @__PURE__ */ new Map(); for (const subscriber of this.subscribers) { const subscriberName = subscriber.name || "Unknown"; this.circuitBreakers.set( subscriber, new CircuitBreaker(subscriberName, { failureThreshold: 5, resetTimeout: 3e4, // 30s windowSize: 6e4 // 1min }) ); } } /** * Automatically enrich attributes with all available telemetry context * * Auto-captures: * - Resource attributes: service.version, deployment.environment * - Trace context: traceId, spanId, correlationId * - Operation context: operation.name */ enrichWithTelemetryContext(attributes = {}) { const enriched = { service: this.serviceName, ...attributes }; const config = getConfig(); if (config) { if (config.version) { enriched["service.version"] = config.version; } if (config.environment) { enriched["deployment.environment"] = config.environment; } } const span = trace.getActiveSpan(); const spanContext = span?.spanContext(); if (spanContext) { enriched.traceId = spanContext.traceId; enriched.spanId = spanContext.spanId; enriched.correlationId = spanContext.traceId.slice(0, 16); } const operationContext = getOperationContext(); if (operationContext) { enriched["operation.name"] = operationContext.name; } return enriched; } /** * Build autotel event context for trace correlation * * Works in 4 contexts: * 1. Inside a span → use current span's trace_id + span_id * 2. Outside span but in AsyncLocalStorage context → use trace_id + correlation_id * 3. Totally standalone → use correlation_id + service/env/version * 4. Batch/fan-in (multiple linked parents) → use count + hash or full array * * @returns AutotelEventContext or undefined if trace context is disabled */ buildAutotelContext() { const eventsConfig = getEventsConfig(); if (!eventsConfig?.includeTraceContext) { return { correlation_id: getOrCreateCorrelationId() }; } const config = getConfig(); const span = trace.getActiveSpan(); const spanContext = span?.spanContext(); const correlationId = getOrCreateCorrelationId(); const autotelContext = { correlation_id: correlationId }; 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) { let traceStateStr = ""; try { if (typeof traceState.serialize === "function") { traceStateStr = traceState.serialize(); } } catch { } if (traceStateStr) { autotelContext.trace_state = traceStateStr; } } if (eventsConfig.traceUrl) { const traceUrl = eventsConfig.traceUrl({ traceId: spanContext.traceId, spanId: spanContext.spanId, correlationId, serviceName: config?.service || this.serviceName, 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; } /** * Enrich event attributes from baggage with guardrails * * @param attributes - Current event attributes * @returns Enriched attributes with baggage values */ enrichFromBaggage(attributes) { const eventsConfig = getEventsConfig(); const enrichConfig = eventsConfig?.enrichFromBaggage; if (!enrichConfig) { return attributes; } const enriched = { ...attributes }; const activeContext = context.active(); const baggage = propagation.getBaggage(activeContext); if (!baggage) { return enriched; } let keyCount = 0; let byteCount = 0; const maxKeys = enrichConfig.maxKeys ?? 10; const maxBytes = enrichConfig.maxBytes ?? 1024; const prefix = enrichConfig.prefix ?? ""; for (const [key, entry] of baggage.getAllEntries()) { if (!this.isBaggageKeyAllowed(key, enrichConfig)) { continue; } if (keyCount >= maxKeys) { break; } const value = entry.value; const transform = enrichConfig.transform?.[key]; let transformedValue; if (transform === "hash") { transformedValue = hashValue(value); } else if (transform === "plain" || !transform) { transformedValue = value; } else if (typeof transform === "function") { transformedValue = transform(value); } else { transformedValue = value; } const valueBytes = new TextEncoder().encode(transformedValue).length; if (byteCount + valueBytes > maxBytes) { continue; } const enrichedKey = `${prefix}${key}`; enriched[enrichedKey] = transformedValue; keyCount++; byteCount += valueBytes; } return enriched; } /** * Check if a baggage key is allowed based on config */ isBaggageKeyAllowed(key, config) { if (config.deny) { for (const pattern of config.deny) { if (this.matchesBaggagePattern(key, pattern)) { return false; } } } for (const pattern of config.allow) { if (this.matchesBaggagePattern(key, pattern)) { return true; } } return false; } /** * Check if a key matches a baggage pattern * Supports exact matches and wildcard patterns (e.g., 'tenant.*') */ matchesBaggagePattern(key, pattern) { if (pattern.endsWith(".*")) { const prefix = pattern.slice(0, -2); return key.startsWith(prefix + "."); } return key === pattern; } /** * Track a business event * * Use this for tracking user actions, business events, product usage: * - "user.signup" * - "order.completed" * - "feature.used" * * Events are sent to configured subscribers (PostHog, Mixpanel, etc.). * * @example * ```typescript * // Track user signup * events.trackEvent('user.signup', { * userId: '123', * plan: 'pro' * }) * * // Track order * events.trackEvent('order.completed', { * orderId: 'ord_123', * amount: 99.99 * }) * ``` */ trackEvent(eventName, attributes) { const validationConfig = getValidationConfig(); const validated = validateEvent( eventName, attributes, validationConfig || void 0 ); const enrichedAttributes = this.enrichWithTelemetryContext( validated.attributes ); this.logger?.info( { event: validated.eventName, attributes: enrichedAttributes }, "Event tracked" ); this.collector?.recordEvent({ event: validated.eventName, attributes: enrichedAttributes, service: this.serviceName, timestamp: Date.now() }); if (this.hasSubscribers) { const autotelContext = this.buildAutotelContext(); const finalAttributes = this.enrichFromBaggage(enrichedAttributes); void this.notifySubscribers( (subscriber) => subscriber.trackEvent(validated.eventName, finalAttributes, { autotel: autotelContext }) ); } } /** * Notify all subscribers concurrently without blocking * Uses circuit breakers to protect against failing subscribers * Uses Promise.allSettled to prevent subscriber errors from affecting other subscribers */ async notifySubscribers(fn) { const promises = this.subscribers.map(async (subscriber) => { const circuitBreaker = this.circuitBreakers.get(subscriber); if (!circuitBreaker) return; try { await circuitBreaker.execute(() => fn(subscriber)); } catch (error) { if (error instanceof CircuitOpenError) { getLogger().warn( { subscriberName: subscriber.name || "Unknown" }, `[Events] ${error.message}` ); return; } getLogger().error( { err: error instanceof Error ? error : void 0, subscriberName: subscriber.name || "Unknown" }, `[Events] Subscriber ${subscriber.name || "Unknown"} failed` ); } }); await Promise.allSettled(promises); } /** * Track conversion funnel steps * * Monitor where users drop off in multi-step processes. * * @example * ```typescript * // Track signup funnel * events.trackFunnelStep('signup', 'started', { userId: '123' }) * events.trackFunnelStep('signup', 'email_verified', { userId: '123' }) * events.trackFunnelStep('signup', 'completed', { userId: '123' }) * * // Track checkout flow * events.trackFunnelStep('checkout', 'started', { cartValue: 99.99 }) * events.trackFunnelStep('checkout', 'payment_info', { cartValue: 99.99 }) * events.trackFunnelStep('checkout', 'completed', { cartValue: 99.99 }) * ``` */ trackFunnelStep(funnelName, status, attributes) { const enrichedAttributes = this.enrichWithTelemetryContext(attributes); this.logger?.info( { funnel: funnelName, status, attributes: enrichedAttributes }, "Funnel step tracked" ); this.collector?.recordFunnelStep({ funnel: funnelName, status, attributes: enrichedAttributes, service: this.serviceName, timestamp: Date.now() }); if (this.hasSubscribers) { const autotelContext = this.buildAutotelContext(); const finalAttributes = this.enrichFromBaggage(enrichedAttributes); void this.notifySubscribers( (subscriber) => subscriber.trackFunnelStep(funnelName, status, finalAttributes, { autotel: autotelContext }) ); } } /** * Track outcomes (success/failure/partial) * * Monitor success rates of critical operations. * * @example * ```typescript * // Track email delivery * events.trackOutcome('email.delivery', 'success', { * recipientType: 'user', * emailType: 'welcome' * }) * * events.trackOutcome('email.delivery', 'failure', { * recipientType: 'user', * errorCode: 'invalid_email' * }) * * // Track payment processing * events.trackOutcome('payment.process', 'success', { amount: 99.99 }) * events.trackOutcome('payment.process', 'failure', { error: 'insufficient_funds' }) * ``` */ trackOutcome(operationName, status, attributes) { const enrichedAttributes = this.enrichWithTelemetryContext(attributes); this.logger?.info( { operation: operationName, status, attributes: enrichedAttributes }, "Outcome tracked" ); this.collector?.recordOutcome({ operation: operationName, status, attributes: enrichedAttributes, service: this.serviceName, timestamp: Date.now() }); if (this.hasSubscribers) { const autotelContext = this.buildAutotelContext(); const finalAttributes = this.enrichFromBaggage(enrichedAttributes); void this.notifySubscribers( (subscriber) => subscriber.trackOutcome(operationName, status, finalAttributes, { autotel: autotelContext }) ); } } /** * Track value metrics * * Record numerical values like revenue, transaction amounts, * item counts, processing times, engagement scores, etc. * * @example * ```typescript * // Track revenue * events.trackValue('order.revenue', 149.99, { * currency: 'USD', * productCategory: 'electronics' * }) * * // Track items per cart * events.trackValue('cart.item_count', 5, { * userId: '123' * }) * * // Track processing time * events.trackValue('api.response_time', 250, { * unit: 'ms', * endpoint: '/api/checkout' * }) * ``` */ trackValue(metricName, value, attributes) { const enrichedAttributes = this.enrichWithTelemetryContext({ metric: metricName, ...attributes }); this.logger?.debug( { metric: metricName, value, attributes: enrichedAttributes }, "Value tracked" ); this.collector?.recordValue({ metric: metricName, value, attributes: enrichedAttributes, service: this.serviceName, timestamp: Date.now() }); if (this.hasSubscribers) { const autotelContext = this.buildAutotelContext(); const finalAttributes = this.enrichFromBaggage(enrichedAttributes); void this.notifySubscribers( (subscriber) => subscriber.trackValue(metricName, value, finalAttributes, { autotel: autotelContext }) ); } } /** * Flush all subscribers and wait for pending events * * Call this before shutdown to ensure all events are delivered. * * @example * ```typescript * const event =new Event('app', { subscribers: [...] }); * * // Before shutdown * await events.flush(); * ``` */ async flush() { if (!this.hasSubscribers) return; const shutdownPromises = this.subscribers.map(async (subscriber) => { if (subscriber.shutdown) { try { await subscriber.shutdown(); } catch (error) { getLogger().error( { err: error instanceof Error ? error : void 0, subscriberName: subscriber.name || "Unknown" }, `[Events] Failed to shutdown subscriber ${subscriber.name || "Unknown"}` ); } } }); await Promise.allSettled(shutdownPromises); } /** * Shutdown the Event instance and all subscribers * * Unlike `flush()`, this method: * - Shuts down all subscribers * - Prevents further event tracking (hasSubscribers becomes false) * - Should only be called once at application shutdown * * @example * ```typescript * // In Next.js API route with after() * import { after } from 'next/server'; * * export async function POST(req: Request) { * const event = new Event('checkout', { subscribers: [...] }); * event.trackEvent('order.completed', { orderId: '123' }); * * after(async () => { * await event.shutdown(); * }); * * return Response.json({ success: true }); * } * ``` */ async shutdown() { if (!this.hasSubscribers) return; await Promise.allSettled( this.subscribers.map(async (subscriber) => { if (subscriber.shutdown) { try { await subscriber.shutdown(); } catch (error) { getLogger().error( { err: error instanceof Error ? error : void 0, subscriberName: subscriber.name || "Unknown" }, `[Events] Failed to shutdown subscriber ${subscriber.name || "Unknown"}` ); } } }) ); this.hasSubscribers = false; } /** * Track funnel progression with custom step names * * Unlike trackFunnelStep which uses FunnelStatus enum values, * this method allows any string as the step name for flexible funnel tracking. * * @param funnelName - Name of the funnel (e.g., "checkout", "onboarding") * @param stepName - Custom step name (e.g., "cart_viewed", "payment_entered") * @param stepNumber - Optional numeric position in the funnel * @param attributes - Optional event attributes * * @example * ```typescript * // Track custom checkout steps * event.trackFunnelProgression('checkout', 'cart_viewed', 1); * event.trackFunnelProgression('checkout', 'shipping_selected', 2); * event.trackFunnelProgression('checkout', 'payment_entered', 3); * event.trackFunnelProgression('checkout', 'order_confirmed', 4); * ``` */ trackFunnelProgression(funnelName, stepName, stepNumber, attributes) { const enrichedAttributes = this.enrichWithTelemetryContext(attributes); this.logger?.info( { funnel: funnelName, stepName, stepNumber, attributes: enrichedAttributes }, "Funnel progression tracked" ); this.collector?.recordFunnelStep({ funnel: funnelName, status: stepName, // Cast for testing collector attributes: { ...enrichedAttributes, step_name: stepName, ...stepNumber === void 0 ? {} : { step_number: stepNumber } }, service: this.serviceName, timestamp: Date.now() }); if (this.hasSubscribers) { const autotelContext = this.buildAutotelContext(); const finalAttributes = this.enrichFromBaggage(enrichedAttributes); void this.notifySubscribers(async (subscriber) => { await (subscriber.trackFunnelProgression ? subscriber.trackFunnelProgression( funnelName, stepName, stepNumber, finalAttributes, { autotel: autotelContext } ) : ( // Fall back to trackFunnelStep with step as custom name (cast) subscriber.trackFunnelStep( funnelName, stepName, { ...finalAttributes, step_name: stepName, ...stepNumber === void 0 ? {} : { step_number: stepNumber } }, { autotel: autotelContext } ) )); }); } } /** * Track multiple events in a batch * * Useful for bulk event tracking with consistent timestamps. * Events are sent to subscribers individually but processed together. * * @param events - Array of events to track * * @example * ```typescript * event.trackBatch([ * { name: 'item.viewed', attributes: { itemId: '1' } }, * { name: 'item.viewed', attributes: { itemId: '2' } }, * { name: 'cart.updated', attributes: { itemCount: 2 } }, * ]); * ``` */ trackBatch(events) { for (const event of events) { const filteredAttributes = event.attributes ? Object.fromEntries( Object.entries(event.attributes).filter( ([, v]) => v !== void 0 && v !== null ) ) : void 0; this.trackEvent(event.name, filteredAttributes); } } }; var eventsInstances = /* @__PURE__ */ new Map(); function getEvents(serviceName, logger) { if (!eventsInstances.has(serviceName)) { eventsInstances.set(serviceName, new Event(serviceName, { logger })); } return eventsInstances.get(serviceName); } function resetEvents() { eventsInstances.clear(); } export { Event, getEvents, resetEvents }; //# sourceMappingURL=chunk-BJ2XPN77.js.map //# sourceMappingURL=chunk-BJ2XPN77.js.map