UNPKG

autotel

Version:
761 lines (757 loc) 24 kB
import { i as getLogger, n as getConfig, o as getValidationConfig, r as getEventsConfig } from "./init-BS2JVkrL.js"; import { i as validateEvent, l as getOrCreateCorrelationId } from "./track-COUuU48p.js"; import { t as getOperationContext } from "./operation-context-CKBoA4Qy.js"; import { TraceFlags, context, propagation, trace } from "@opentelemetry/api"; //#region src/circuit-breaker.ts const DEFAULT_CONFIG = { failureThreshold: 5, resetTimeout: 3e4, windowSize: 6e4 }; const CircuitState = { CLOSED: "CLOSED", OPEN: "OPEN", HALF_OPEN: "HALF_OPEN" }; /** * Circuit breaker implementation * * Tracks failures and automatically opens the circuit to prevent * overwhelming failing subscribers. */ 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(); } }; /** * Error thrown when circuit is open */ var CircuitOpenError = class extends Error { constructor(message) { super(message); this.name = "CircuitOpenError"; } }; //#endregion //#region src/events-config.ts /** * Hash a string value for PII protection * * Uses a simple, fast hash function suitable for correlation. * NOT cryptographically secure - use for PII masking, not security. */ 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"); } //#endregion //#region src/event.ts /** * Events API for product events platforms * * Track user behavior, business events, and critical actions. * Sends to product events platforms (PostHog, Mixpanel, Amplitude) via subscribers. * For business people who think in events/funnels. * * For OpenTelemetry metrics (Prometheus/Grafana), use the Metrics class instead. * * @example Recommended: Configure subscribers in init(), use track() function * ```typescript * import { init, track } from 'autotel'; * import { PostHogSubscriber } from 'autotel-subscribers/posthog'; * * init({ * service: 'my-app', * subscribers: [new PostHogSubscriber({ apiKey: 'phc_...' })] * }); * * // Track events - uses subscribers from init() * track('application.submitted', { jobId: '123', userId: '456' }); * ``` * * @example Create Event instance (inherits subscribers from init) * ```typescript * import { Event } from 'autotel/event'; * * // Uses subscribers configured in init() * const event = new Event('job-application'); * event.trackEvent('application.submitted', { jobId: '123' }); * ``` * * @example Override subscribers for specific Event instance * ```typescript * import { Event } from 'autotel/event'; * import { PostHogSubscriber } from 'autotel-subscribers/posthog'; * * // Override: use different subscribers for this instance * const event = new Event('job-application', { * subscribers: [new PostHogSubscriber({ apiKey: 'phc_different_project' })] * }); * * event.trackEvent('application.submitted', { jobId: '123' }); * ``` */ var Event = class { serviceName; logger; collector; subscribers; hasSubscribers; circuitBreakers; /** * 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, windowSize: 6e4 })); } } /** * 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 spanContext = trace.getActiveSpan()?.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 spanContext = trace.getActiveSpan()?.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 enrichConfig = getEventsConfig()?.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 validated = validateEvent(eventName, attributes, getValidationConfig() || 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); 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); 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); 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); 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, 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); this.notifySubscribers(async (subscriber) => { await (subscriber.trackFunnelProgression ? subscriber.trackFunnelProgression(funnelName, stepName, stepNumber, finalAttributes, { autotel: autotelContext }) : 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); } } }; /** * Global events instances (singleton pattern) */ const eventsInstances = /* @__PURE__ */ new Map(); /** * Get or create an Events instance for a service * * @param serviceName - Service name for identifying events * @param logger - Optional logger * @returns Events instance * * @example * ```typescript * const event =getEvents('job-application') * events.trackEvent('application.submitted', { jobId: '123' }) * ``` */ function getEvents(serviceName, logger) { if (!eventsInstances.has(serviceName)) eventsInstances.set(serviceName, new Event(serviceName, { logger })); return eventsInstances.get(serviceName); } /** * Reset all events instances (mainly for testing) */ function resetEvents() { eventsInstances.clear(); } //#endregion export { getEvents as n, resetEvents as r, Event as t }; //# sourceMappingURL=event-531asIM6.js.map