autotel
Version:
Write Once, Observe Anywhere
517 lines (515 loc) • 17.6 kB
JavaScript
import { a as trace$1 } from "./functional-r-AUIRy_.js";
import { t as emitCorrelatedEvent } from "./correlated-events-Bzh5y-UB.js";
import { createSafeBaggageSchema } from "./business-baggage.js";
import { SpanKind, context, propagation } from "@opentelemetry/api";
//#region src/workflow-distributed.ts
/**
* Distributed workflow tracing with cross-service correlation
*
* Enables tracking workflows that span multiple microservices by propagating
* workflow identity (workflowId, stepName, stepIndex) via baggage in message headers.
*
* Unlike local workflow.ts (which uses AsyncLocalStorage), distributed workflows
* propagate context across network boundaries using W3C baggage.
*
* @example Order fulfillment saga across services
* ```typescript
* // Service A: Order Service
* import { traceDistributedWorkflow, WorkflowBaggage } from 'autotel/workflow-distributed';
* import { traceProducer } from 'autotel/messaging';
*
* export const createOrder = traceDistributedWorkflow({
* name: 'OrderFulfillment',
* workflowIdFrom: (order) => order.id,
* version: '1.0.0',
* })(ctx => async (order: Order) => {
* // Workflow baggage is auto-set
* await publishToInventory(order);
* });
*
* const publishToInventory = traceProducer({
* system: 'kafka',
* destination: 'inventory-requests',
* propagateBaggage: true, // Includes workflow.* baggage
* })(ctx => async (order) => {
* await producer.send({ topic: 'inventory-requests', value: order });
* });
*
* // Service B: Inventory Service
* import { traceDistributedStep, WorkflowBaggage } from 'autotel/workflow-distributed';
*
* export const processInventory = traceDistributedStep({
* name: 'ReserveInventory',
* extractBaggage: true, // Extracts workflow.* from headers
* })(ctx => async (message) => {
* const workflow = WorkflowBaggage.get(ctx);
* // workflow.workflowId === order.id (propagated from Service A)
* console.log(`Processing step for workflow ${workflow.workflowId}`);
* await reserveItems(message.items);
* });
* ```
*
* @module
*/
/**
* Workflow baggage field definitions
*/
const workflowBaggageFields = {
/** Unique identifier for the workflow instance */
workflowId: {
type: "string",
maxLength: 128,
required: true
},
/** Name/type of the workflow (e.g., "OrderFulfillment") */
workflowName: {
type: "string",
maxLength: 64,
required: true
},
/** Version of the workflow definition */
workflowVersion: {
type: "string",
maxLength: 32
},
/** Current step name */
stepName: {
type: "string",
maxLength: 64
},
/** Current step index (0-based) */
stepIndex: { type: "number" },
/** Total number of steps (if known) */
totalSteps: { type: "number" },
/** Parent workflow ID (for sub-workflows) */
parentWorkflowId: {
type: "string",
maxLength: 128
},
/** Correlation ID for external systems */
correlationId: {
type: "string",
maxLength: 128
},
/** Workflow priority */
priority: {
type: "enum",
values: [
"low",
"normal",
"high",
"critical"
]
},
/** Initiating user/system */
initiatedBy: {
type: "string",
maxLength: 64
},
/** Workflow start timestamp (ISO) */
startedAt: {
type: "string",
maxLength: 30
}
};
/**
* Pre-built baggage schema for distributed workflows
*
* Use this to read/write workflow context that propagates across services.
*
* @example Setting workflow baggage
* ```typescript
* WorkflowBaggage.set(ctx, {
* workflowId: 'order-12345',
* workflowName: 'OrderFulfillment',
* stepName: 'ReserveInventory',
* stepIndex: 1,
* });
* ```
*
* @example Reading workflow baggage in downstream service
* ```typescript
* const { workflowId, workflowName, stepIndex } = WorkflowBaggage.get(ctx);
* console.log(`Processing ${workflowName} step ${stepIndex}`);
* ```
*/
const WorkflowBaggage = createSafeBaggageSchema(workflowBaggageFields, {
prefix: "workflow",
hashHighCardinality: false,
redactPII: false
});
/**
* Create a traced distributed workflow function
*
* Wraps a function as the entry point for a distributed workflow. Automatically:
* - Generates or extracts workflow ID
* - Sets workflow baggage for downstream propagation
* - Creates root span with workflow attributes
*
* @param config - Workflow configuration
* @returns Factory function for the workflow handler
*
* @example Basic usage
* ```typescript
* export const createOrder = traceDistributedWorkflow({
* name: 'OrderFulfillment',
* workflowIdFrom: (order) => order.id,
* version: '1.0.0',
* })(ctx => async (order: Order) => {
* ctx.recordStepProgress('ValidateOrder', 0);
* await validateOrder(order);
*
* ctx.recordStepProgress('ReserveInventory', 1);
* await publishToInventoryService(order);
*
* return { workflowId: ctx.workflowId, status: 'started' };
* });
* ```
*/
function traceDistributedWorkflow(config) {
const spanName = `workflow.${config.name}`;
return (fnFactory) => {
return trace$1({
name: spanName,
spanKind: SpanKind.INTERNAL
}, (baseCtx) => {
return async (...args) => {
const workflowId = config.workflowIdFrom(...args);
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
const baggageValues = {
workflowId,
workflowName: config.name,
workflowVersion: config.version,
stepIndex: 0,
totalSteps: config.totalSteps,
parentWorkflowId: config.parentWorkflowId,
correlationId: config.correlationId,
priority: config.priority,
initiatedBy: config.initiatedBy,
startedAt
};
WorkflowBaggage.set(baseCtx, baggageValues);
baseCtx.setAttribute("workflow.id", workflowId);
baseCtx.setAttribute("workflow.name", config.name);
if (config.version) baseCtx.setAttribute("workflow.version", config.version);
if (config.totalSteps) baseCtx.setAttribute("workflow.total_steps", config.totalSteps);
if (config.parentWorkflowId) baseCtx.setAttribute("workflow.parent_id", config.parentWorkflowId);
if (config.priority) baseCtx.setAttribute("workflow.priority", config.priority);
if (config.initiatedBy) baseCtx.setAttribute("workflow.initiated_by", config.initiatedBy);
baseCtx.setAttribute("workflow.started_at", startedAt);
if (config.attributes) for (const [key, value] of Object.entries(config.attributes)) baseCtx.setAttribute(key, value);
const workflowCtx = {
...baseCtx,
workflowId,
workflowName: config.name,
workflowVersion: config.version,
getWorkflowBaggage() {
return { ...baggageValues };
},
setWorkflowBaggage(values) {
Object.assign(baggageValues, values);
WorkflowBaggage.set(baseCtx, baggageValues);
},
getWorkflowHeaders() {
const headers = {};
const ctx = context.active();
propagation.inject(ctx, headers);
return headers;
},
recordStepProgress(stepName, stepIndex) {
baggageValues.stepName = stepName;
baggageValues.stepIndex = stepIndex;
WorkflowBaggage.set(baseCtx, baggageValues);
emitCorrelatedEvent(baseCtx, "workflow.step_progress", {
"workflow.step.name": stepName,
"workflow.step.index": stepIndex
});
}
};
config.onStart?.(workflowCtx);
emitCorrelatedEvent(baseCtx, "workflow.started", {
"workflow.id": workflowId,
"workflow.name": config.name
});
try {
const result = await fnFactory(workflowCtx)(...args);
config.onComplete?.(workflowCtx, result);
emitCorrelatedEvent(baseCtx, "workflow.completed", { "workflow.id": workflowId });
return result;
} catch (error) {
config.onError?.(workflowCtx, error);
emitCorrelatedEvent(baseCtx, "workflow.failed", {
"workflow.id": workflowId,
"workflow.error": error.message
});
throw error;
}
};
});
};
}
/**
* Create a traced distributed workflow step
*
* Use in downstream services to trace steps that are part of a distributed workflow.
* Automatically extracts workflow baggage from the current context.
*
* @param config - Step configuration
* @returns Factory function for the step handler
*
* @example Consumer in downstream service
* ```typescript
* export const processInventory = traceConsumer({
* system: 'kafka',
* destination: 'inventory-requests',
* extractBaggage: true, // Extracts workflow.* from headers
* })(ctx => {
* // Wrap inner logic with traceDistributedStep
* return traceDistributedStep({
* name: 'ReserveInventory',
* })(stepCtx => async (message) => {
* console.log(`Processing workflow ${stepCtx.workflowId}`);
* await reserveItems(message.items);
* })(message);
* });
* ```
*
* @example Standalone step handler
* ```typescript
* export const reserveInventory = traceDistributedStep({
* name: 'ReserveInventory',
* idempotent: true,
* })(ctx => async (request: InventoryRequest) => {
* if (ctx.workflowId) {
* console.log(`Part of workflow ${ctx.workflowId}, step ${ctx.stepIndex}`);
* }
* return await inventoryService.reserve(request.items);
* });
* ```
*/
function traceDistributedStep(config) {
const spanName = `workflow.step.${config.name}`;
return (fnFactory) => {
return trace$1({
name: spanName,
spanKind: SpanKind.INTERNAL
}, (baseCtx) => {
return async (...args) => {
let baggageValues = null;
const extractBaggage = config.extractBaggage ?? true;
if (typeof extractBaggage === "function") baggageValues = extractBaggage(args);
else if (extractBaggage) {
const extracted = WorkflowBaggage.get(baseCtx);
if (extracted.workflowId && extracted.workflowName) baggageValues = extracted;
}
let stepIndex;
if (config.stepIndex !== void 0) stepIndex = config.stepIndex;
else if (baggageValues?.stepIndex === void 0) stepIndex = null;
else stepIndex = baggageValues.stepIndex + 1;
if (baggageValues) {
baggageValues.stepName = config.name;
if (stepIndex !== null) baggageValues.stepIndex = stepIndex;
WorkflowBaggage.set(baseCtx, baggageValues);
}
baseCtx.setAttribute("workflow.step.name", config.name);
if (stepIndex !== null) baseCtx.setAttribute("workflow.step.index", stepIndex);
if (config.idempotent !== void 0) baseCtx.setAttribute("workflow.step.idempotent", config.idempotent);
if (config.isCompensation) baseCtx.setAttribute("workflow.step.is_compensation", true);
if (baggageValues) {
baseCtx.setAttribute("workflow.id", baggageValues.workflowId);
baseCtx.setAttribute("workflow.name", baggageValues.workflowName);
if (baggageValues.workflowVersion) baseCtx.setAttribute("workflow.version", baggageValues.workflowVersion);
if (baggageValues.totalSteps) baseCtx.setAttribute("workflow.total_steps", baggageValues.totalSteps);
}
if (config.attributes) for (const [key, value] of Object.entries(config.attributes)) baseCtx.setAttribute(key, value);
let compensationData;
const stepCtx = {
...baseCtx,
workflowId: baggageValues?.workflowId ?? null,
workflowName: baggageValues?.workflowName ?? null,
stepName: config.name,
stepIndex,
isCompensation: config.isCompensation ?? false,
getWorkflowBaggage() {
return baggageValues ? { ...baggageValues } : null;
},
updateWorkflowBaggage(values) {
if (baggageValues) {
Object.assign(baggageValues, values);
WorkflowBaggage.set(baseCtx, baggageValues);
}
},
getWorkflowHeaders() {
const headers = {};
const ctx = context.active();
propagation.inject(ctx, headers);
return headers;
},
requiresCompensation(data) {
compensationData = data;
baseCtx.setAttribute("workflow.step.requires_compensation", true);
emitCorrelatedEvent(baseCtx, "workflow.step.compensation_registered", {
"workflow.step.name": config.name,
...data && { "workflow.step.compensation_data": JSON.stringify(data) }
});
}
};
config.onStart?.(stepCtx);
emitCorrelatedEvent(baseCtx, "workflow.step.started", {
"workflow.step.name": config.name,
...baggageValues && { "workflow.id": baggageValues.workflowId }
});
try {
const result = await fnFactory(stepCtx)(...args);
config.onComplete?.(stepCtx, result);
emitCorrelatedEvent(baseCtx, "workflow.step.completed", { "workflow.step.name": config.name });
return result;
} catch (error) {
config.onError?.(stepCtx, error);
emitCorrelatedEvent(baseCtx, "workflow.step.failed", {
"workflow.step.name": config.name,
"workflow.step.error": error.message,
...compensationData && { "workflow.step.requires_compensation": true }
});
throw error;
}
};
});
};
}
/**
* Generate a unique workflow ID
*
* @param prefix - Optional prefix for the ID
* @returns A unique workflow ID
*
* @example
* ```typescript
* const workflowId = generateWorkflowId('order'); // "order-abc123def456"
* ```
*/
function generateWorkflowId(prefix) {
const random = Math.random().toString(36).slice(2, 15);
const id = `${Date.now().toString(36)}-${random}`;
return prefix ? `${prefix}-${id}` : id;
}
/**
* Check if the current context is part of a distributed workflow
*
* @param ctx - The trace context
* @returns True if workflow baggage is present
*/
function isInDistributedWorkflow(ctx) {
const baggage = WorkflowBaggage.get(ctx);
return !!(baggage.workflowId && baggage.workflowName);
}
/**
* Get workflow progress information
*
* @param ctx - The trace context
* @returns Progress info or null if not in a workflow
*/
function getWorkflowProgress(ctx) {
const baggage = WorkflowBaggage.get(ctx);
if (!baggage.workflowId || !baggage.workflowName) return null;
const percentComplete = baggage.totalSteps && baggage.stepIndex !== void 0 ? Math.round((baggage.stepIndex + 1) / baggage.totalSteps * 100) : null;
return {
workflowId: baggage.workflowId,
workflowName: baggage.workflowName,
currentStep: baggage.stepName ?? null,
currentStepIndex: baggage.stepIndex ?? null,
totalSteps: baggage.totalSteps ?? null,
percentComplete
};
}
/**
* Create workflow correlation headers for manual propagation
*
* Use when you need to manually add workflow context to outgoing requests.
*
* @param values - Workflow baggage values
* @returns Headers object with workflow baggage
*
* @example
* ```typescript
* const headers = createWorkflowHeaders({
* workflowId: 'order-123',
* workflowName: 'OrderFulfillment',
* stepIndex: 2,
* });
*
* await fetch('/api/inventory', { headers });
* ```
*/
function createWorkflowHeaders(values) {
const headers = {};
const baggageEntries = [];
if (values.workflowId) baggageEntries.push(`workflow.workflowId=${encodeURIComponent(values.workflowId)}`);
if (values.workflowName) baggageEntries.push(`workflow.workflowName=${encodeURIComponent(values.workflowName)}`);
if (values.workflowVersion) baggageEntries.push(`workflow.workflowVersion=${encodeURIComponent(values.workflowVersion)}`);
if (values.stepName) baggageEntries.push(`workflow.stepName=${encodeURIComponent(values.stepName)}`);
if (values.stepIndex !== void 0) baggageEntries.push(`workflow.stepIndex=${values.stepIndex}`);
if (values.totalSteps !== void 0) baggageEntries.push(`workflow.totalSteps=${values.totalSteps}`);
if (values.priority) baggageEntries.push(`workflow.priority=${values.priority}`);
if (values.correlationId) baggageEntries.push(`workflow.correlationId=${encodeURIComponent(values.correlationId)}`);
if (values.parentWorkflowId) baggageEntries.push(`workflow.parentWorkflowId=${encodeURIComponent(values.parentWorkflowId)}`);
if (values.initiatedBy) baggageEntries.push(`workflow.initiatedBy=${encodeURIComponent(values.initiatedBy)}`);
if (values.startedAt) baggageEntries.push(`workflow.startedAt=${encodeURIComponent(values.startedAt)}`);
if (baggageEntries.length > 0) headers["baggage"] = baggageEntries.join(",");
return headers;
}
/**
* Parse workflow context from baggage header
*
* @param baggageHeader - The baggage header value
* @returns Parsed workflow values or null
*/
function parseWorkflowFromBaggage(baggageHeader) {
if (!baggageHeader) return null;
const values = {};
const entries = baggageHeader.split(",");
for (const entry of entries) {
const [key, value] = entry.trim().split("=");
if (!key || !value) continue;
const decodedValue = decodeURIComponent(value);
switch (key) {
case "workflow.workflowId":
values.workflowId = decodedValue;
break;
case "workflow.workflowName":
values.workflowName = decodedValue;
break;
case "workflow.workflowVersion":
values.workflowVersion = decodedValue;
break;
case "workflow.stepName":
values.stepName = decodedValue;
break;
case "workflow.stepIndex":
values.stepIndex = Number.parseInt(decodedValue, 10);
break;
case "workflow.totalSteps":
values.totalSteps = Number.parseInt(decodedValue, 10);
break;
case "workflow.priority":
values.priority = decodedValue;
break;
case "workflow.correlationId":
values.correlationId = decodedValue;
break;
case "workflow.parentWorkflowId":
values.parentWorkflowId = decodedValue;
break;
case "workflow.initiatedBy":
values.initiatedBy = decodedValue;
break;
case "workflow.startedAt":
values.startedAt = decodedValue;
break;
}
}
return Object.keys(values).length > 0 ? values : null;
}
//#endregion
export { WorkflowBaggage, createWorkflowHeaders, generateWorkflowId, getWorkflowProgress, isInDistributedWorkflow, parseWorkflowFromBaggage, traceDistributedStep, traceDistributedWorkflow };
//# sourceMappingURL=workflow-distributed.js.map