Getting Started
Requirements
- Node.js 24+ — the engine uses
AsyncLocalStorageand supportsError.cause. experimentalDecorators, if you use any decorator. The plain functions work without it.
Installation
npm install execution-engineIf you use decorators, enable legacy decorator support in tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true
}
}Legacy decorators, not TC39
This library uses the legacy decorator proposal, not the TC39 Stage 3 decorators TypeScript 5 enables by default. Without this flag, every decorator errors.
Trace one function
For a single method, @trace reports every call without requiring an engine:
import { trace } from 'execution-engine';
class MathOperations {
@trace((ctx) => console.log(ctx.metadata.name, ctx.inputs, ctx.outputs, ctx.elapsedTime))
add(a: number, b: number) {
return a + b;
}
}
new MathOperations().add(2, 3);
// add [ 2, 3 ] 5 0.041 msThe method still returns 5. For a plain function, use executionTrace instead. Trace documents both APIs.
Trace a workflow
To capture how several calls relate, route them through an engine. Each call becomes a node; the engine connects the nodes into a graph:
import { ExecutionEngine } from 'execution-engine';
const engine = new ExecutionEngine();
// Synchronous functions return the trace directly:
const res1 = engine.run(function step1(param) {
return `result1 for ${param}`;
}, ['param1']);
// Asynchronous functions return a promise of the trace:
const res2 = await engine.run(async function step2(param) {
return `result2 for ${param}`;
}, [res1.outputs]);
console.log(res2.outputs); // "result2 for result1 for param1"
console.log(JSON.stringify(engine.getTrace(), null, 2));run returns an ExecutionTrace: the function result plus the data recorded about the call. The function's return value is on .outputs.
Name your functions
Node labels come from Function.prototype.name. An inline arrow function has no name, so it shows up in the trace as "function". Use a named function — or pass an explicit label, as shown below — to get a readable graph.
To control the label yourself:
const res = engine.run(fetchUser, [userId], {
trace: { id: 'fetch-user', label: 'Fetch user' }
});A class-based workflow
For class-based code, @engine and @run remove the wrapper calls. Methods return their own values while the engine records the calls.
import { engine, run, EngineTask } from 'execution-engine';
@engine({ id: 'uniqueEngineId' })
class MyClass extends EngineTask {
@run()
myMethod1(param: string) {
return `result1 for ${param}`;
}
@run()
async myMethod2(param: string) {
return `result2 for ${param}`;
}
}
const myInstance = new MyClass();
const a = myInstance.myMethod1('param1'); // "result1 for param1"
const b = await myInstance.myMethod2(a); // "result2 for result1 for param1"
console.log(myInstance.engine.getTrace());Unlike engine.run, @run returns the method's value directly, not an ExecutionTrace. Read the graph afterwards from this.engine.
Visualizing the result
getTrace() returns a Cytoscape-compatible array of nodes and edges. Write it to a file and drop that file into the json-to-graph viewer to explore the run as an interactive diagram:
import { writeFileSync } from 'node:fs';
writeFileSync('trace.json', JSON.stringify(engine.getTrace(), null, 2));Where to go next
- Examples — runs you can read side by side with their graphs.
- Trace — what nodes and edges mean, and how nesting and parallelism are inferred.
- Which API to use — whether you actually need the full engine, or just
@trace.