autotel
Version:
Write Once, Observe Anywhere
404 lines (402 loc) • 12.6 kB
JavaScript
import { getActiveSpan } from "./trace-helpers.js";
import { a as trace } from "./functional-r-AUIRy_.js";
import { t as emitCorrelatedEvent } from "./correlated-events-Bzh5y-UB.js";
import * as nodeAsyncHooks from "node:async_hooks";
//#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 nodeAsyncHooks.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 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 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);
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) 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 = 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 = 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 {
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) {
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);
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 = getActiveSpan();
if (span) {
const state = workflowStates.get(span);
if (state) stepIndex = config.index ?? state.stepCounter++;
}
}
if (workflowCtx) {
const wfSpan = 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);
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 = 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
export { getCurrentWorkflowContext, isInWorkflow, traceStep, traceWorkflow };
//# sourceMappingURL=workflow.js.map