UNPKG

autotel

Version:
410 lines (408 loc) 13.2 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); const require_chunk = require('./chunk-C_NdSu1c.cjs'); const require_trace_helpers = require('./trace-helpers.cjs'); const require_functional = require('./functional-C8B0Qa7o.cjs'); const require_correlated_events = require('./correlated-events-kSwLo3mi.cjs'); let node_async_hooks = require("node:async_hooks"); node_async_hooks = require_chunk.__toESM(node_async_hooks, 1); //#region src/workflow.ts /** * Workflow and Saga tracing helpers * * Provides specialized tracing for multi-step workflows and sagas with * automatic step linking, compensation tracking, and workflow correlation. * * @example Simple workflow * ```typescript * import { traceWorkflow, traceStep } from 'autotel/workflow'; * * export const processOrder = traceWorkflow({ * name: 'OrderFulfillment', * workflowId: (order) => order.id, * })(ctx => async (order: Order) => { * await validateOrder(order); * await chargePayment(order); * await shipOrder(order); * }); * ``` * * @example Saga with compensation * ```typescript * import { traceWorkflow, traceStep } from 'autotel/workflow'; * * export const orderSaga = traceWorkflow({ * name: 'OrderSaga', * workflowId: () => generateUUID(), * })(ctx => async (order: Order) => { * * const reserveStep = traceStep({ * name: 'ReserveInventory', * compensate: async () => { * await releaseInventory(order.items); * }, * })(async () => { * await inventoryService.reserve(order.items); * }); * await reserveStep(); * * const paymentStep = traceStep({ * name: 'ProcessPayment', * linkToPrevious: true, * compensate: async () => { * await refundPayment(order.paymentId); * }, * })(async () => { * await paymentService.charge(order); * }); * await paymentStep(); * }); * ``` * * @module */ const workflowStates = /* @__PURE__ */ new WeakMap(); /** * AsyncLocalStorage for workflow context (async-safe) * * This replaces the previous module-level variable which was NOT safe for * concurrent workflows. AsyncLocalStorage ensures each async execution chain * has its own isolated workflow context. */ const workflowContextStorage = new node_async_hooks.AsyncLocalStorage(); /** * Create a traced workflow function * * Wraps business logic in a workflow span with automatic step tracking, * correlation via workflow ID, and compensation support. * * @param config - Workflow configuration * @returns Factory function that wraps your workflow logic * * @example Order fulfillment workflow * ```typescript * export const fulfillOrder = traceWorkflow({ * name: 'OrderFulfillment', * workflowId: (order) => order.id, * version: '2.0', * })(ctx => async (order: Order) => { * ctx.setAttribute('order.total', order.total); * * await validateOrder(order); * await processPayment(order); * await fulfillItems(order); * await notifyCustomer(order); * * return { success: true, orderId: order.id }; * }); * ``` */ function traceWorkflow(config) { const spanName = `workflow.${config.name}`; return (fnFactory) => { return require_functional.trace(spanName, (baseCtx) => { return async (...args) => { const workflowId = typeof config.workflowId === "function" ? config.workflowId(...args) : config.workflowId; const ctx = createWorkflowContext(baseCtx, config.name, workflowId); ctx.setAttribute("workflow.name", config.name); ctx.setAttribute("workflow.id", workflowId); if (config.version) ctx.setAttribute("workflow.version", config.version); ctx.setAttribute("workflow.status", "running"); if (config.attributes) { for (const [key, value] of Object.entries(config.attributes)) if (value !== void 0) ctx.setAttribute(key, value); } return workflowContextStorage.run(ctx, async () => { try { const result = await fnFactory(ctx)(...args); ctx.setWorkflowStatus("completed"); config.onComplete?.(ctx, result); return result; } catch (error) { ctx.setWorkflowStatus("failed"); config.onFailed?.(ctx, error); const state = getWorkflowState(); if (state && state.compensations.size > 0) { ctx.setWorkflowStatus("compensating"); config.onCompensating?.(ctx); try { await ctx.compensate(error); ctx.setWorkflowStatus("compensated"); } catch (compensationError) { ctx.setWorkflowStatus("compensation_failed"); ctx.setAttribute("workflow.compensation.error", String(compensationError)); } } throw error; } }); }; }); }; } /** * Create a traced workflow step * * Wraps step logic with automatic linking to previous steps, * compensation registration, and status tracking. * * @param config - Step configuration * @returns Factory function that wraps your step logic * * @example Step with compensation * ```typescript * const chargePayment = traceStep({ * name: 'ChargePayment', * linkToPrevious: true, * compensate: async (error) => { * await paymentService.refund(paymentId); * }, * })(async (amount: number) => { * return await paymentService.charge(amount); * }); * ``` */ function traceStep(config) { return (fn) => { return require_functional.trace(`step.${config.name}`, (baseCtx) => { return async (...args) => { const workflowCtx = workflowContextStorage.getStore() ?? null; const ctx = createStepContext(baseCtx, config, workflowCtx); ctx.setAttribute("workflow.step.name", config.name); ctx.setAttribute("workflow.step.index", ctx.getStepIndex()); ctx.setAttribute("workflow.step.status", "running"); if (config.description) ctx.setAttribute("workflow.step.description", config.description); if (config.idempotent) ctx.setAttribute("workflow.step.idempotent", true); if (config.attributes) { for (const [key, value] of Object.entries(config.attributes)) if (value !== void 0) ctx.setAttribute(key, value); } await addStepLinks(ctx, config, workflowCtx); if (config.compensate && workflowCtx) workflowCtx.registerCompensation(config.name, config.compensate); let lastError; const maxAttempts = config.retry?.maxAttempts ?? 1; for (let attempt = 1; attempt <= maxAttempts; attempt++) try { if (attempt > 1) { ctx.setAttribute("workflow.step.retry_attempt", attempt); require_correlated_events.emitCorrelatedEvent(ctx, "step_retry", { "workflow.step.attempt": attempt, "workflow.step.max_attempts": maxAttempts }); if (config.retry?.backoffMs) await sleep(config.retry.backoffMs * attempt); } const result = await fn(...args); ctx.setAttribute("workflow.step.status", "completed"); ctx.complete(); config.onComplete?.(ctx); return result; } catch (error) { lastError = error; if (attempt < maxAttempts) require_correlated_events.emitCorrelatedEvent(ctx, "step_retry_scheduled", { "workflow.step.error": String(error), "workflow.step.attempt": attempt }); } ctx.setAttribute("workflow.step.status", "failed"); ctx.setAttribute("workflow.step.error", String(lastError)); config.onFailed?.(ctx, lastError); throw lastError; }; }); }; } /** * Create workflow-extended context */ function createWorkflowContext(baseCtx, workflowName, workflowId) { const span = require_trace_helpers.getActiveSpan(); const state = { workflowId, workflowName, status: "running", steps: /* @__PURE__ */ new Map(), stepCounter: 0, compensations: /* @__PURE__ */ new Map() }; if (span) workflowStates.set(span, state); return { ...baseCtx, getWorkflowId() { return workflowId; }, getWorkflowName() { return workflowName; }, getStatus() { return state.status; }, completeStep(stepName) { let step = state.steps.get(stepName); if (!step) { step = { name: stepName, index: state.stepCounter++, status: "pending", startTime: Date.now() }; state.steps.set(stepName, step); } step.status = "completed"; step.endTime = Date.now(); const currentSpan = require_trace_helpers.getActiveSpan(); if (currentSpan) step.spanContext = currentSpan.spanContext(); }, getPreviousStep(stepName) { if (stepName) return state.steps.get(stepName)?.spanContext ?? null; let lastStep = null; for (const step of state.steps.values()) if (step.status === "completed" && (!lastStep || step.index > lastStep.index)) lastStep = step; return lastStep?.spanContext ?? null; }, getCompletedSteps() { const completed = []; for (const [name, step] of state.steps) if (step.status === "completed") completed.push(name); return completed.toSorted((a, b) => (state.steps.get(a)?.index ?? 0) - (state.steps.get(b)?.index ?? 0)); }, registerCompensation(stepName, handler) { state.compensations.set(stepName, handler); }, async compensate(error) { const compensationOrder = [...state.compensations.entries()].toReversed(); for (const [stepName, handler] of compensationOrder) { const step = state.steps.get(stepName); if (step && step.status === "completed") try { require_correlated_events.emitCorrelatedEvent(baseCtx, "compensation_started", { "workflow.step.name": stepName }); await Promise.resolve(handler(error)); this.recordCompensation(stepName, true); step.status = "compensated"; } catch (compensationError) { this.recordCompensation(stepName, false, compensationError); throw compensationError; } } }, recordCompensation(stepName, success, error) { require_correlated_events.emitCorrelatedEvent(baseCtx, "compensation_completed", { "workflow.step.name": stepName, "workflow.compensation.success": success, ...error && { "workflow.compensation.error": String(error) } }); baseCtx.setAttribute(`workflow.compensation.${stepName}`, success ? "success" : "failed"); }, setWorkflowStatus(status) { state.status = status; baseCtx.setAttribute("workflow.status", status); require_correlated_events.emitCorrelatedEvent(baseCtx, "workflow_status_changed", { "workflow.status": status }); } }; } /** * Create step-extended context */ function createStepContext(baseCtx, config, workflowCtx) { let stepIndex = config.index ?? 0; if (workflowCtx) { const span = require_trace_helpers.getActiveSpan(); if (span) { const state = workflowStates.get(span); if (state) stepIndex = config.index ?? state.stepCounter++; } } if (workflowCtx) { const wfSpan = require_trace_helpers.getActiveSpan(); if (wfSpan) { const state = workflowStates.get(wfSpan); if (state) state.steps.set(config.name, { name: config.name, index: stepIndex, status: "running", startTime: Date.now(), compensate: config.compensate }); } } return { ...baseCtx, getStepName() { return config.name; }, getStepIndex() { return stepIndex; }, complete() { if (workflowCtx) workflowCtx.completeStep(config.name); }, skip(reason) { baseCtx.setAttribute("workflow.step.status", "skipped"); if (reason) baseCtx.setAttribute("workflow.step.skip_reason", reason); require_correlated_events.emitCorrelatedEvent(baseCtx, "step_skipped", { "workflow.step.name": config.name, ...reason && { "workflow.step.skip_reason": reason } }); }, getWorkflowContext() { return workflowCtx; } }; } /** * Get workflow state from context */ function getWorkflowState() { const span = require_trace_helpers.getActiveSpan(); return span ? workflowStates.get(span) : null; } /** * Add links to previous steps */ async function addStepLinks(ctx, config, workflowCtx) { if (!workflowCtx) return; const links = []; if (config.linkToPrevious) { const prevSpanContext = workflowCtx.getPreviousStep(); if (prevSpanContext) links.push({ context: prevSpanContext, attributes: { "workflow.link.type": "sequence" } }); } if (config.linkTo) { const stepNames = Array.isArray(config.linkTo) ? config.linkTo : [config.linkTo]; for (const stepName of stepNames) { const spanContext = workflowCtx.getPreviousStep(stepName); if (spanContext) links.push({ context: spanContext, attributes: { "workflow.link.type": "dependency", "workflow.link.step": stepName } }); } } if (links.length > 0) ctx.addLinks(links); } /** * Sleep utility for retry backoff */ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get current workflow context (if inside a workflow) * * Uses AsyncLocalStorage to ensure async-safety when multiple * workflows are running concurrently. */ function getCurrentWorkflowContext() { return workflowContextStorage.getStore() ?? null; } /** * Check if currently executing inside a workflow * * Uses AsyncLocalStorage to ensure async-safety when multiple * workflows are running concurrently. */ function isInWorkflow() { return workflowContextStorage.getStore() !== void 0; } //#endregion exports.getCurrentWorkflowContext = getCurrentWorkflowContext; exports.isInWorkflow = isInWorkflow; exports.traceStep = traceStep; exports.traceWorkflow = traceWorkflow; //# sourceMappingURL=workflow.cjs.map