UNPKG

autotel

Version:
297 lines (295 loc) 9.14 kB
import { r as recordStructuredError } from "./structured-error-9--cxBay.js"; import { a as trace$1 } from "./functional-r-AUIRy_.js"; import { t as emitCorrelatedEvent } from "./correlated-events-Bzh5y-UB.js"; import { SpanKind, trace } from "@opentelemetry/api"; //#region src/webhook.ts /** * Webhook and callback tracing with the "Parking Lot" pattern * * When initiating async operations that return hours/days later (webhooks, * payment callbacks, human approvals), you can't keep a span open. This module * provides utilities to "park" trace context and retrieve it when callbacks arrive. * * @example Stripe payment webhook * ```typescript * import { createParkingLot, InMemoryTraceContextStore } from 'autotel/webhook'; * * const parkingLot = createParkingLot({ * store: new InMemoryTraceContextStore(), * defaultTTLMs: 24 * 60 * 60 * 1000, // 24 hours * }); * * // When initiating payment * export const initiatePayment = trace(ctx => async (orderId: string) => { * await parkingLot.park(`payment:${orderId}`, { orderId }); * await stripeClient.createPaymentIntent({ metadata: { orderId } }); * }); * * // When Stripe webhook arrives (hours later) * export const handleStripeWebhook = parkingLot.traceCallback({ * name: 'stripe.webhook.payment_intent.succeeded', * correlationKeyFrom: (event) => `payment:${event.data.object.metadata.orderId}`, * })(ctx => async (event: Stripe.Event) => { * // ctx.parkedContext contains the original trace context * // ctx.elapsedMs shows time since payment was initiated * await fulfillOrder(event.data.object); * }); * ``` * * @module */ /** * In-memory trace context store * * Useful for testing and development. For production, use a persistent * store like Redis or DynamoDB. * * @example * ```typescript * const store = new InMemoryTraceContextStore(); * const parkingLot = createParkingLot({ store }); * ``` */ var InMemoryTraceContextStore = class { options; store = /* @__PURE__ */ new Map(); cleanupInterval = null; constructor(options = {}) { this.options = options; const cleanupMs = options.cleanupIntervalMs ?? 6e4; if (cleanupMs > 0) { this.cleanupInterval = setInterval(() => this.cleanup(), cleanupMs); if (this.cleanupInterval.unref) this.cleanupInterval.unref(); } } async save(key, context) { this.store.set(key, context); } async load(key) { const context = this.store.get(key); if (!context) return null; if (context.ttlMs) { if (Date.now() - context.parkedAt > context.ttlMs) { this.store.delete(key); return null; } } return context; } async delete(key) { this.store.delete(key); } /** * Get number of stored contexts (for testing) */ get size() { return this.store.size; } /** * Clear all stored contexts (for testing) */ clear() { this.store.clear(); } /** * Stop the cleanup interval */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } cleanup() { const now = Date.now(); for (const [key, context] of this.store.entries()) if (context.ttlMs) { if (now - context.parkedAt > context.ttlMs) this.store.delete(key); } } }; /** * Create a parking lot for trace context storage and retrieval * * @param config - Parking lot configuration * @returns A parking lot instance * * @example Basic usage * ```typescript * const parkingLot = createParkingLot({ * store: new InMemoryTraceContextStore(), * defaultTTLMs: 24 * 60 * 60 * 1000, // 24 hours * }); * ``` * * @example With Redis store * ```typescript * class RedisTraceContextStore implements TraceContextStore { * constructor(private redis: Redis) {} * * async save(key: string, context: StoredTraceContext) { * const ttlSeconds = context.ttlMs ? Math.ceil(context.ttlMs / 1000) : 86400; * await this.redis.setex(key, ttlSeconds, JSON.stringify(context)); * } * * async load(key: string) { * const data = await this.redis.get(key); * return data ? JSON.parse(data) : null; * } * * async delete(key: string) { * await this.redis.del(key); * } * } * * const parkingLot = createParkingLot({ * store: new RedisTraceContextStore(redis), * }); * ``` */ function createParkingLot(config) { const { store, defaultTTLMs = 1440 * 60 * 1e3, keyPrefix = "parkingLot:", autoDeleteOnRetrieve = true, onMiss } = config; /** * Get current span context from active context */ function getCurrentSpanContext() { const activeSpan = trace.getActiveSpan(); if (!activeSpan) return null; return activeSpan.spanContext(); } /** * Apply key prefix */ function prefixKey(key) { return `${keyPrefix}${key}`; } const parkingLot = { async park(correlationKey, metadata) { const spanContext = getCurrentSpanContext(); const fullKey = prefixKey(correlationKey); const storedContext = { traceId: spanContext?.traceId ?? "", spanId: spanContext?.spanId ?? "", traceFlags: spanContext?.traceFlags ?? 0, parkedAt: Date.now(), ttlMs: defaultTTLMs, metadata }; await store.save(fullKey, storedContext); const activeSpan = trace.getActiveSpan(); if (activeSpan) emitCorrelatedEvent({ setAttribute: (k, v) => activeSpan.setAttribute(k, v), setAttributes: (a) => activeSpan.setAttributes(a), addEvent: (n, a) => activeSpan.addEvent(n, a) }, "trace_context_parked", { "parking_lot.correlation_key": correlationKey, "parking_lot.ttl_ms": defaultTTLMs, ...metadata && Object.fromEntries(Object.entries(metadata).map(([k, v]) => [`parking_lot.metadata.${k}`, v])) }); return correlationKey; }, async retrieve(correlationKey) { const fullKey = prefixKey(correlationKey); const storedContext = await store.load(fullKey); if (!storedContext) { onMiss?.(correlationKey); return null; } if (autoDeleteOnRetrieve) await store.delete(fullKey); return storedContext; }, traceCallback(callbackConfig) { return (fnFactory) => { return trace$1({ name: callbackConfig.name, spanKind: SpanKind.SERVER }, (baseCtx) => { return async (...args) => { const correlationKey = callbackConfig.correlationKeyFrom(args); const parkedContext = await parkingLot.retrieve(correlationKey); const elapsedMs = parkedContext ? Date.now() - parkedContext.parkedAt : null; baseCtx.setAttribute("parking_lot.correlation_key", correlationKey); if (parkedContext) { baseCtx.setAttribute("parking_lot.elapsed_ms", elapsedMs); baseCtx.setAttribute("parking_lot.original_trace_id", parkedContext.traceId); baseCtx.setAttribute("parking_lot.original_span_id", parkedContext.spanId); if (parkedContext.metadata) for (const [key, value] of Object.entries(parkedContext.metadata)) baseCtx.setAttribute(`parking_lot.metadata.${key}`, value); const link = parkingLot.createLink(parkedContext); baseCtx.addLinks([link]); emitCorrelatedEvent(baseCtx, "parked_context_retrieved", { "parking_lot.correlation_key": correlationKey, "parking_lot.elapsed_ms": elapsedMs, "parking_lot.original_trace_id": parkedContext.traceId }); } else { baseCtx.setAttribute("parking_lot.context_found", false); if (callbackConfig.requireParkedContext) { const error = /* @__PURE__ */ new Error(`Required parked context not found for key: ${correlationKey}`); recordStructuredError(baseCtx, error); throw error; } } if (callbackConfig.attributes) for (const [key, value] of Object.entries(callbackConfig.attributes)) baseCtx.setAttribute(key, value); return fnFactory({ ...baseCtx, parkedContext, elapsedMs, correlationKey })(...args); }; }); }; }, createLink(storedContext) { return { context: { traceId: storedContext.traceId, spanId: storedContext.spanId, traceFlags: storedContext.traceFlags, isRemote: true }, attributes: { "link.type": "parking_lot", "parking_lot.parked_at": storedContext.parkedAt, ...storedContext.metadata && { "parking_lot.has_metadata": true } } }; }, async exists(correlationKey) { const fullKey = prefixKey(correlationKey); return await store.load(fullKey) !== null; } }; return parkingLot; } /** * Create a correlation key from multiple parts * * @param parts - Key parts to join * @returns A correlation key string * * @example * ```typescript * const key = createCorrelationKey('payment', orderId, 'stripe'); * // Returns: "payment:order-123:stripe" * ``` */ function createCorrelationKey(...parts) { return parts.map(String).join(":"); } /** * Extract span context from stored context for manual linking * * @param storedContext - The stored trace context * @returns SpanContext compatible object */ function toSpanContext(storedContext) { return { traceId: storedContext.traceId, spanId: storedContext.spanId, traceFlags: storedContext.traceFlags, isRemote: true }; } //#endregion export { InMemoryTraceContextStore, createCorrelationKey, createParkingLot, toSpanContext }; //# sourceMappingURL=webhook.js.map