autotel
Version:
Write Once, Observe Anywhere
1,214 lines (1,207 loc) • 39.6 kB
JavaScript
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