autotel
Version:
Write Once, Observe Anywhere
301 lines (299 loc) • 9.48 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
const require_structured_error = require('./structured-error-CHg7DoIQ.cjs');
const require_functional = require('./functional-C8B0Qa7o.cjs');
const require_correlated_events = require('./correlated-events-kSwLo3mi.cjs');
let _opentelemetry_api = require("@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 = _opentelemetry_api.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 = _opentelemetry_api.trace.getActiveSpan();
if (activeSpan) require_correlated_events.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 require_functional.trace({
name: callbackConfig.name,
spanKind: _opentelemetry_api.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]);
require_correlated_events.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}`);
require_structured_error.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
exports.InMemoryTraceContextStore = InMemoryTraceContextStore;
exports.createCorrelationKey = createCorrelationKey;
exports.createParkingLot = createParkingLot;
exports.toSpanContext = toSpanContext;
//# sourceMappingURL=webhook.cjs.map