flowcraft 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +9 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/config/tsconfig.json +21 -0
- package/config/tsup.config.ts +11 -0
- package/config/vitest.config.ts +11 -0
- package/docs/.vitepress/config.ts +105 -0
- package/docs/api-reference/builder.md +158 -0
- package/docs/api-reference/fn.md +142 -0
- package/docs/api-reference/index.md +38 -0
- package/docs/api-reference/workflow.md +126 -0
- package/docs/guide/advanced-guides/cancellation.md +117 -0
- package/docs/guide/advanced-guides/composition.md +68 -0
- package/docs/guide/advanced-guides/custom-executor.md +180 -0
- package/docs/guide/advanced-guides/error-handling.md +135 -0
- package/docs/guide/advanced-guides/logging.md +106 -0
- package/docs/guide/advanced-guides/middleware.md +106 -0
- package/docs/guide/advanced-guides/observability.md +175 -0
- package/docs/guide/best-practices/debugging.md +182 -0
- package/docs/guide/best-practices/state-management.md +120 -0
- package/docs/guide/best-practices/sub-workflow-data.md +95 -0
- package/docs/guide/best-practices/testing.md +187 -0
- package/docs/guide/builders.md +157 -0
- package/docs/guide/functional-api.md +133 -0
- package/docs/guide/index.md +178 -0
- package/docs/guide/recipes/creating-a-loop.md +113 -0
- package/docs/guide/recipes/data-processing-pipeline.md +123 -0
- package/docs/guide/recipes/fan-out-fan-in.md +112 -0
- package/docs/guide/recipes/index.md +15 -0
- package/docs/guide/recipes/resilient-api-call.md +110 -0
- package/docs/guide/tooling/graph-validation.md +160 -0
- package/docs/guide/tooling/mermaid.md +156 -0
- package/docs/index.md +56 -0
- package/eslint.config.js +16 -0
- package/package.json +40 -0
- package/pnpm-workspace.yaml +2 -0
- package/sandbox/1.basic/README.md +45 -0
- package/sandbox/1.basic/package.json +16 -0
- package/sandbox/1.basic/src/flow.ts +17 -0
- package/sandbox/1.basic/src/main.ts +22 -0
- package/sandbox/1.basic/src/nodes.ts +112 -0
- package/sandbox/1.basic/src/utils.ts +35 -0
- package/sandbox/1.basic/tsconfig.json +3 -0
- package/sandbox/2.research/README.md +46 -0
- package/sandbox/2.research/package.json +16 -0
- package/sandbox/2.research/src/flow.ts +14 -0
- package/sandbox/2.research/src/main.ts +31 -0
- package/sandbox/2.research/src/nodes.ts +108 -0
- package/sandbox/2.research/src/utils.ts +45 -0
- package/sandbox/2.research/src/visualize.ts +29 -0
- package/sandbox/2.research/tsconfig.json +3 -0
- package/sandbox/3.parallel/README.md +65 -0
- package/sandbox/3.parallel/package.json +16 -0
- package/sandbox/3.parallel/src/main.ts +45 -0
- package/sandbox/3.parallel/src/nodes.ts +43 -0
- package/sandbox/3.parallel/src/utils.ts +25 -0
- package/sandbox/3.parallel/tsconfig.json +3 -0
- package/sandbox/4.dag/README.md +179 -0
- package/sandbox/4.dag/data/1.blog-post/100.json +60 -0
- package/sandbox/4.dag/data/1.blog-post/README.md +25 -0
- package/sandbox/4.dag/data/2.job-application/200.json +103 -0
- package/sandbox/4.dag/data/2.job-application/201.json +31 -0
- package/sandbox/4.dag/data/2.job-application/202.json +31 -0
- package/sandbox/4.dag/data/2.job-application/README.md +58 -0
- package/sandbox/4.dag/data/3.customer-review/300.json +141 -0
- package/sandbox/4.dag/data/3.customer-review/301.json +31 -0
- package/sandbox/4.dag/data/3.customer-review/302.json +28 -0
- package/sandbox/4.dag/data/3.customer-review/README.md +71 -0
- package/sandbox/4.dag/data/4.content-moderation/400.json +161 -0
- package/sandbox/4.dag/data/4.content-moderation/401.json +47 -0
- package/sandbox/4.dag/data/4.content-moderation/402.json +46 -0
- package/sandbox/4.dag/data/4.content-moderation/403.json +31 -0
- package/sandbox/4.dag/data/4.content-moderation/README.md +83 -0
- package/sandbox/4.dag/package.json +19 -0
- package/sandbox/4.dag/src/main.ts +73 -0
- package/sandbox/4.dag/src/nodes.ts +134 -0
- package/sandbox/4.dag/src/registry.ts +87 -0
- package/sandbox/4.dag/src/types.ts +25 -0
- package/sandbox/4.dag/src/utils.ts +42 -0
- package/sandbox/4.dag/tsconfig.json +3 -0
- package/sandbox/5.distributed/.env.example +1 -0
- package/sandbox/5.distributed/README.md +88 -0
- package/sandbox/5.distributed/data/1.blog-post/100.json +59 -0
- package/sandbox/5.distributed/data/1.blog-post/README.md +25 -0
- package/sandbox/5.distributed/data/2.job-application/200.json +103 -0
- package/sandbox/5.distributed/data/2.job-application/201.json +30 -0
- package/sandbox/5.distributed/data/2.job-application/202.json +30 -0
- package/sandbox/5.distributed/data/2.job-application/README.md +58 -0
- package/sandbox/5.distributed/data/3.customer-review/300.json +141 -0
- package/sandbox/5.distributed/data/3.customer-review/301.json +31 -0
- package/sandbox/5.distributed/data/3.customer-review/302.json +57 -0
- package/sandbox/5.distributed/data/3.customer-review/README.md +71 -0
- package/sandbox/5.distributed/data/4.content-moderation/400.json +173 -0
- package/sandbox/5.distributed/data/4.content-moderation/401.json +47 -0
- package/sandbox/5.distributed/data/4.content-moderation/402.json +46 -0
- package/sandbox/5.distributed/data/4.content-moderation/403.json +31 -0
- package/sandbox/5.distributed/data/4.content-moderation/README.md +83 -0
- package/sandbox/5.distributed/package.json +20 -0
- package/sandbox/5.distributed/src/client.ts +124 -0
- package/sandbox/5.distributed/src/executor.ts +69 -0
- package/sandbox/5.distributed/src/nodes.ts +136 -0
- package/sandbox/5.distributed/src/registry.ts +101 -0
- package/sandbox/5.distributed/src/types.ts +45 -0
- package/sandbox/5.distributed/src/utils.ts +69 -0
- package/sandbox/5.distributed/src/worker.ts +217 -0
- package/sandbox/5.distributed/tsconfig.json +3 -0
- package/sandbox/6.rag/.env.example +1 -0
- package/sandbox/6.rag/README.md +60 -0
- package/sandbox/6.rag/data/README.md +31 -0
- package/sandbox/6.rag/data/rag.json +58 -0
- package/sandbox/6.rag/documents/sample-cascade.txt +11 -0
- package/sandbox/6.rag/package.json +18 -0
- package/sandbox/6.rag/src/main.ts +52 -0
- package/sandbox/6.rag/src/nodes/GenerateEmbeddingsNode.ts +54 -0
- package/sandbox/6.rag/src/nodes/LLMProcessNode.ts +48 -0
- package/sandbox/6.rag/src/nodes/LoadAndChunkNode.ts +40 -0
- package/sandbox/6.rag/src/nodes/StoreInVectorDBNode.ts +36 -0
- package/sandbox/6.rag/src/nodes/VectorSearchNode.ts +53 -0
- package/sandbox/6.rag/src/nodes/index.ts +28 -0
- package/sandbox/6.rag/src/registry.ts +23 -0
- package/sandbox/6.rag/src/types.ts +44 -0
- package/sandbox/6.rag/src/utils.ts +77 -0
- package/sandbox/6.rag/tsconfig.json +3 -0
- package/sandbox/tsconfig.json +13 -0
- package/src/builder/collection.test.ts +287 -0
- package/src/builder/collection.ts +269 -0
- package/src/builder/graph.test.ts +406 -0
- package/src/builder/graph.ts +336 -0
- package/src/builder/graph.types.ts +104 -0
- package/src/builder/index.ts +3 -0
- package/src/context.ts +111 -0
- package/src/errors.ts +34 -0
- package/src/executor.ts +29 -0
- package/src/executors/in-memory.test.ts +93 -0
- package/src/executors/in-memory.ts +140 -0
- package/src/functions.test.ts +191 -0
- package/src/functions.ts +117 -0
- package/src/index.ts +5 -0
- package/src/logger.ts +41 -0
- package/src/types.ts +75 -0
- package/src/utils/graph.test.ts +144 -0
- package/src/utils/graph.ts +182 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/mermaid.test.ts +239 -0
- package/src/utils/mermaid.ts +133 -0
- package/src/utils/sleep.ts +20 -0
- package/src/workflow.test.ts +622 -0
- package/src/workflow.ts +561 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Error Handling
|
|
2
|
+
|
|
3
|
+
Real-world processes can fail. Network connections drop, APIs return errors, and unexpected conditions occur. Flowcraft provides built-in mechanisms for making your workflows resilient through automatic retries and fallback logic.
|
|
4
|
+
|
|
5
|
+
## How Errors are Handled
|
|
6
|
+
|
|
7
|
+
When an error is thrown inside a `Node`, Flowcraft wraps it in a `WorkflowError` object. This custom error provides additional context, including:
|
|
8
|
+
|
|
9
|
+
- `nodeName`: The name of the `Node` class where the error occurred.
|
|
10
|
+
- `phase`: The lifecycle phase (`'prep'`, `'exec'`, or `'post'`) where the error was thrown.
|
|
11
|
+
- `originalError`: The underlying error that was caught.
|
|
12
|
+
|
|
13
|
+
This ensures that you always know the precise location of a failure in your workflow.
|
|
14
|
+
|
|
15
|
+
If an unhandled error occurs (meaning there is no fallback logic), it will propagate up and halt the entire `Flow`.
|
|
16
|
+
|
|
17
|
+
## Automatic Retries
|
|
18
|
+
|
|
19
|
+
The most common way to handle transient failures (like a temporary network issue) is to simply retry the operation. You can configure this directly on any `Node` instance through its constructor options.
|
|
20
|
+
|
|
21
|
+
### `maxRetries` and `wait`
|
|
22
|
+
|
|
23
|
+
- `maxRetries`: The total number of times the `exec` phase will be attempted. A value of `1` (the default) means no retries. A value of `3` means one initial attempt and up to two retries.
|
|
24
|
+
- `wait`: The time in milliseconds to wait between retry attempts. Defaults to `0`.
|
|
25
|
+
|
|
26
|
+
> [!IMPORTANT]
|
|
27
|
+
> **Only the `exec` phase is retried.** Errors in `prep` or `post` are considered fatal for that node and will immediately cause a failure. This is by design, as these phases often involve state changes in the `Context` that might not be safe to repeat.
|
|
28
|
+
|
|
29
|
+
### Example: Retrying an API Call
|
|
30
|
+
|
|
31
|
+
Let's create a node that simulates a flaky API call and configure it to retry.
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { ConsoleLogger, Flow, Node, TypedContext } from 'flowcraft'
|
|
35
|
+
|
|
36
|
+
class FlakyApiNode extends Node {
|
|
37
|
+
private attempts = 0
|
|
38
|
+
|
|
39
|
+
constructor() {
|
|
40
|
+
// Configure to try a total of 3 times, waiting 100ms between failures.
|
|
41
|
+
super({ maxRetries: 3, wait: 100 })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async exec() {
|
|
45
|
+
this.attempts++
|
|
46
|
+
console.log(`Calling API, attempt #${this.attempts}...`)
|
|
47
|
+
|
|
48
|
+
if (this.attempts < 3) {
|
|
49
|
+
throw new Error('API is temporarily unavailable!')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log('API call successful!')
|
|
53
|
+
return { data: 'some important data' }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const flow = new Flow(new FlakyApiNode())
|
|
58
|
+
// We'll use a ConsoleLogger to see the retry warnings.
|
|
59
|
+
await flow.run(new TypedContext(), { logger: new ConsoleLogger() })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The output will look like this, demonstrating the retry logic:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
[INFO] Running node: FlakyApiNode
|
|
66
|
+
Calling API, attempt #1...
|
|
67
|
+
[WARN] Attempt 1/3 failed for FlakyApiNode. Retrying...
|
|
68
|
+
Calling API, attempt #2...
|
|
69
|
+
[WARN] Attempt 2/3 failed for FlakyApiNode. Retrying...
|
|
70
|
+
Calling API, attempt #3...
|
|
71
|
+
API call successful!
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Fallback Logic
|
|
75
|
+
|
|
76
|
+
What happens if all retries are exhausted and the `exec` phase still fails? By default, the node throws an error. However, you can provide a safety net by implementing the `execFallback` method.
|
|
77
|
+
|
|
78
|
+
`execFallback` is a special lifecycle method on the `Node` class. It is only called if all `exec` attempts (initial + retries) have failed. It receives the same `args` as `exec`, with the final `error` object included.
|
|
79
|
+
|
|
80
|
+
The return value of `execFallback` will be passed to the `post` phase, just as a successful `exec` result would be. This allows your workflow to gracefully recover and continue, perhaps with default or cached data.
|
|
81
|
+
|
|
82
|
+
### Example: Using a Fallback
|
|
83
|
+
|
|
84
|
+
Let's modify the previous example to handle a permanent failure.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { ConsoleLogger, contextKey, Flow, Node, TypedContext } from 'flowcraft'
|
|
88
|
+
|
|
89
|
+
class ResilientApiNode extends Node<void, { data: string }> {
|
|
90
|
+
constructor() {
|
|
91
|
+
super({ maxRetries: 2 }) // Try twice
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async exec() {
|
|
95
|
+
console.log('Attempting to call the API...')
|
|
96
|
+
throw new Error('API is down!')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// This method runs after all exec retries fail.
|
|
100
|
+
async execFallback({ error }) {
|
|
101
|
+
console.error(`All API attempts failed. Reason: ${error.message}`)
|
|
102
|
+
console.log('Returning cached/default data as a fallback.')
|
|
103
|
+
return { data: 'default fallback data' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async post({ ctx, execRes }) {
|
|
107
|
+
// The post phase doesn't care if exec or execFallback ran.
|
|
108
|
+
// It just receives the result.
|
|
109
|
+
ctx.set(DATA_KEY, execRes.data)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const DATA_KEY = contextKey<string>('data')
|
|
114
|
+
const context = new TypedContext()
|
|
115
|
+
const flow = new Flow(new ResilientApiNode())
|
|
116
|
+
|
|
117
|
+
await flow.run(context, { logger: new ConsoleLogger() })
|
|
118
|
+
|
|
119
|
+
console.log(`Final data in context: ${context.get(DATA_KEY)}`)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The output will be:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
[INFO] Running node: ResilientApiNode
|
|
126
|
+
Attempting to call the API...
|
|
127
|
+
[WARN] Attempt 1/2 failed for ResilientApiNode. Retrying...
|
|
128
|
+
Attempting to call the API...
|
|
129
|
+
[ERROR] All retries failed for ResilientApiNode. Executing fallback.
|
|
130
|
+
All API attempts failed. Reason: API is down!
|
|
131
|
+
Returning cached/default data as a fallback.
|
|
132
|
+
Final data in context: default fallback data
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The workflow completed successfully because the fallback provided a valid result, allowing the `post` phase and the rest of the flow to proceed.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Pluggable Logging
|
|
2
|
+
|
|
3
|
+
Effective logging is essential for debugging and monitoring any application. Flowcraft is designed to be completely unopinionated about your logging strategy. It provides a simple `Logger` interface and includes a few basic implementations, but makes it easy to plug in any logging library you prefer, such as **Pino**, **Winston**, or your company's standard logger.
|
|
4
|
+
|
|
5
|
+
## The `Logger` Interface
|
|
6
|
+
|
|
7
|
+
The framework uses a simple interface to decouple itself from any specific logging implementation. Any logger you provide must conform to this shape:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
export interface Logger {
|
|
11
|
+
debug: (message: string, context?: object) => void
|
|
12
|
+
info: (message: string, context?: object) => void
|
|
13
|
+
warn: (message: string, context?: object) => void
|
|
14
|
+
error: (message: string, context?: object) => void
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The `context` object is used to pass structured data, which is a best practice for modern logging. The Flowcraft engine will automatically pass contextual information here, such as retry attempts or error details.
|
|
19
|
+
|
|
20
|
+
## Using the Built-in Loggers
|
|
21
|
+
|
|
22
|
+
Flowcraft comes with two pre-built loggers:
|
|
23
|
+
|
|
24
|
+
- **`ConsoleLogger`**: A straightforward logger that prints messages to the `console` (e.g., `console.info`, `console.warn`). This is great for development and debugging.
|
|
25
|
+
- **`NullLogger`**: A logger that does nothing.
|
|
26
|
+
|
|
27
|
+
> [!NOTE]
|
|
28
|
+
> **Flowcraft is silent by default.** The `NullLogger` is the framework's default if no logger is provided. This ensures that Flowcraft doesn't clutter your application's output unless you explicitly enable logging by passing a `logger` in the `RunOptions`.
|
|
29
|
+
|
|
30
|
+
To use the `ConsoleLogger`, simply pass it in the `RunOptions` when you execute a flow:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { ConsoleLogger, Flow, Node, TypedContext } from 'flowcraft'
|
|
34
|
+
|
|
35
|
+
const myFlow = new Flow(new Node().exec(() => 'done'))
|
|
36
|
+
const context = new TypedContext()
|
|
37
|
+
|
|
38
|
+
// Run the flow with the console logger enabled
|
|
39
|
+
await myFlow.run(context, { logger: new ConsoleLogger() })
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This will produce detailed output about the flow's execution, including which nodes are running, what actions they return, and any warnings or errors.
|
|
43
|
+
|
|
44
|
+
## Integrating a Custom Logger (e.g., Pino)
|
|
45
|
+
|
|
46
|
+
Plugging in a production-grade logger like [Pino](https://github.com/pinojs/pino) is easy. All you need is a simple adapter class that maps Pino's logging methods to Flowcraft's `Logger` interface.
|
|
47
|
+
|
|
48
|
+
### Example: Pino Logger Adapter
|
|
49
|
+
|
|
50
|
+
First, install pino:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install pino
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then, create an adapter class:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// src/loggers/pino-logger.ts
|
|
60
|
+
import { Logger as FlowcraftLogger } from 'flowcraft'
|
|
61
|
+
import pino, { Logger as PinoLogger } from 'pino'
|
|
62
|
+
|
|
63
|
+
export class PinoFlowcraftLogger implements FlowcraftLogger {
|
|
64
|
+
private pino: PinoLogger
|
|
65
|
+
|
|
66
|
+
constructor(options?: pino.LoggerOptions) {
|
|
67
|
+
// Initialize pino with your desired configuration
|
|
68
|
+
this.pino = pino(options || {
|
|
69
|
+
level: 'info',
|
|
70
|
+
transport: {
|
|
71
|
+
target: 'pino-pretty', // for development
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Map the interface methods to pino's methods
|
|
77
|
+
public debug(message: string, context?: object): void {
|
|
78
|
+
this.pino.debug(context || {}, message)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public info(message: string, context?: object): void {
|
|
82
|
+
this.pino.info(context || {}, message)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public warn(message: string, context?: object): void {
|
|
86
|
+
this.pino.warn(context || {}, message)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public error(message: string, context?: object): void {
|
|
90
|
+
this.pino.error(context || {}, message)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Now you can use your custom logger just like a built-in one:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// main.ts
|
|
99
|
+
import { PinoFlowcraftLogger } from './loggers/pino-logger'
|
|
100
|
+
|
|
101
|
+
const pinoLogger = new PinoFlowcraftLogger()
|
|
102
|
+
|
|
103
|
+
await myFlow.run(context, { logger: pinoLogger })
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Your workflow's execution logs will now be formatted as structured JSON (or pretty-printed, depending on your Pino configuration), ready to be shipped to a log aggregation service like Datadog, Logstash, or Splunk.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Middleware
|
|
2
|
+
|
|
3
|
+
Middleware provides a powerful mechanism to hook into the execution of nodes within a `Flow`. It allows you to wrap the logic of every node, making it the ideal pattern for handling **cross-cutting concerns** without cluttering your business logic.
|
|
4
|
+
|
|
5
|
+
## What is Middleware?
|
|
6
|
+
|
|
7
|
+
A middleware is a function that sits between the `Executor` and the `Node` it is about to execute. It receives the node's arguments and a `next` function. The middleware can perform actions before calling `next()` to proceed with the node's execution, and after `next()` returns to process the result.
|
|
8
|
+
|
|
9
|
+
This is conceptually similar to middleware in web frameworks like Express or Koa.
|
|
10
|
+
|
|
11
|
+
## Common Use Cases
|
|
12
|
+
|
|
13
|
+
- **Performance Monitoring**: Start a timer before `next()` and record the duration after it completes to measure how long each node takes.
|
|
14
|
+
- **Authentication/Authorization**: Check if the current context has valid credentials before allowing a node to run.
|
|
15
|
+
- **Transaction Management**: Start a database transaction before the first node in a flow and commit or roll it back after the flow completes.
|
|
16
|
+
- **Input/Output Validation**: Validate a node's parameters before execution or its results after execution.
|
|
17
|
+
- **Centralized Logging**: Implement structured logging for every node's entry and exit. See the [Logging Guide](./logging.md) for more on this topic.
|
|
18
|
+
|
|
19
|
+
## How to Use Middleware
|
|
20
|
+
|
|
21
|
+
You can add middleware to any `Flow` instance using the `.use()` method. You can add multiple middleware functions; they will be executed in the order they are added.
|
|
22
|
+
|
|
23
|
+
### The Middleware Function Signature
|
|
24
|
+
|
|
25
|
+
A middleware function has the following signature:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
type Middleware = (args: NodeArgs, next: MiddlewareNext) => Promise<any>
|
|
29
|
+
type MiddlewareNext = (args: NodeArgs) => Promise<any>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `args`: The `NodeArgs` object, containing the `ctx`, `params`, `logger`, etc., for the node about to be executed.
|
|
33
|
+
- `next`: A function that you must call to pass control to the next middleware in the chain, or to the node itself if it's the last one.
|
|
34
|
+
|
|
35
|
+
### Example: Database Transaction Middleware
|
|
36
|
+
|
|
37
|
+
A classic use case for middleware is managing a database transaction. We want to start a transaction before the flow runs and either `COMMIT` it on success or `ROLLBACK` on failure.
|
|
38
|
+
|
|
39
|
+
Let's assume we have a `dbClient` with `beginTransaction`, `commit`, and `rollback` methods.
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// A mock database client
|
|
43
|
+
const dbClient = {
|
|
44
|
+
beginTransaction: async () => console.log('[DB] ==> BEGIN TRANSACTION'),
|
|
45
|
+
commit: async () => console.log('[DB] <== COMMIT'),
|
|
46
|
+
rollback: async () => console.log('[DB] <== ROLLBACK'),
|
|
47
|
+
query: async (sql: string) => console.log(`[DB] Executing: ${sql}`),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Our transaction middleware
|
|
51
|
+
async function transactionMiddleware(args, next) {
|
|
52
|
+
// This middleware only acts on the top-level Flow, not individual nodes.
|
|
53
|
+
// We check the node's name to apply the logic selectively.
|
|
54
|
+
if (args.name !== 'TransactionFlow') {
|
|
55
|
+
return next(args)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await dbClient.beginTransaction()
|
|
59
|
+
try {
|
|
60
|
+
// Call next() to execute the entire wrapped flow
|
|
61
|
+
const result = await next(args)
|
|
62
|
+
await dbClient.commit()
|
|
63
|
+
return result
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.error('[DB] Error occurred, rolling back transaction.')
|
|
67
|
+
await dbClient.rollback()
|
|
68
|
+
// Re-throw the error so the top-level caller knows the flow failed
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Nodes that simulate database operations
|
|
74
|
+
class CreateUserNode extends Node {
|
|
75
|
+
async exec() { await dbClient.query('INSERT INTO users...') }
|
|
76
|
+
}
|
|
77
|
+
class UpdateProfileNode extends Node {
|
|
78
|
+
async exec() { await dbClient.query('UPDATE profiles...') }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A specific Flow class to attach the middleware to
|
|
82
|
+
class TransactionFlow extends Flow {}
|
|
83
|
+
|
|
84
|
+
// Create a flow with a couple of nodes
|
|
85
|
+
const flow = new TransactionFlow(new CreateUserNode())
|
|
86
|
+
flow.startNode.next(new UpdateProfileNode())
|
|
87
|
+
|
|
88
|
+
// Apply the middleware
|
|
89
|
+
flow.use(transactionMiddleware)
|
|
90
|
+
|
|
91
|
+
// Run it
|
|
92
|
+
console.log('--- Running successful transaction ---')
|
|
93
|
+
await flow.run(new TypedContext())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
When this runs, the transaction is correctly committed:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
--- Running successful transaction ---
|
|
100
|
+
[DB] ==> BEGIN TRANSACTION
|
|
101
|
+
[DB] Executing: INSERT INTO users...
|
|
102
|
+
[DB] Executing: UPDATE profiles...
|
|
103
|
+
[DB] <== COMMIT
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If a node were to fail, the `try...catch` block in the middleware would catch the error and issue a `ROLLBACK`, ensuring data integrity. This powerful pattern keeps your business logic nodes (`CreateUserNode`) clean and unaware of transaction management.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Observability (Tracing & Metrics)
|
|
2
|
+
|
|
3
|
+
For production-grade applications, understanding how your workflows are performing is critical. Observability—through structured logging, distributed tracing, and metrics—allows you to debug issues, identify bottlenecks, and monitor the health of your system.
|
|
4
|
+
|
|
5
|
+
Flowcraft's `Middleware` pattern is the perfect place to integrate observability tooling like **OpenTelemetry** without cluttering your business logic. This guide will show you how to create a middleware that adds tracing and metrics to every node in your flow.
|
|
6
|
+
|
|
7
|
+
## The Goal
|
|
8
|
+
|
|
9
|
+
We want to create a middleware that, for every node execution:
|
|
10
|
+
|
|
11
|
+
1. **Starts a new OpenTelemetry Span**: This creates a "trace" that visualizes the node as a distinct unit of work, showing its duration and relationship to other nodes.
|
|
12
|
+
2. **Adds Attributes**: Enriches the span with useful information like the node's name, the action it returned, and whether it succeeded or failed.
|
|
13
|
+
3. **Records Metrics**: Captures the duration of the node's execution as a histogram metric, allowing you to create dashboards and alerts for performance.
|
|
14
|
+
4. **Propagates Context**: Ensures that any operations performed *inside* the node (like an API call) are part of the same trace.
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
This guide assumes you have a basic OpenTelemetry setup for Node.js. If you don't, please refer to the official [OpenTelemetry for Node.js Getting Started guide](https://opentelemetry.io/docs/instrumentation/js/getting-started/nodejs/).
|
|
19
|
+
|
|
20
|
+
At a minimum, you'll need the following packages:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @opentelemetry/api \
|
|
24
|
+
@opentelemetry/sdk-node \
|
|
25
|
+
@opentelemetry/auto-instrumentations-node
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
And a simple file (`tracing.ts`) to configure the SDK:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
|
|
32
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' // or your preferred exporter
|
|
33
|
+
// src/tracing.ts
|
|
34
|
+
import { NodeSDK } from '@opentelemetry/sdk-node'
|
|
35
|
+
|
|
36
|
+
const sdk = new NodeSDK({
|
|
37
|
+
traceExporter: new OTLPTraceExporter(),
|
|
38
|
+
instrumentations: [getNodeAutoInstrumentations()],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
sdk.start()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## The Observability Middleware
|
|
45
|
+
|
|
46
|
+
Here is a complete, reusable middleware for OpenTelemetry. You can place this in a file like `src/middleware/tracing.ts`.
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import {
|
|
50
|
+
context,
|
|
51
|
+
Meter,
|
|
52
|
+
metrics,
|
|
53
|
+
Span,
|
|
54
|
+
SpanStatusCode,
|
|
55
|
+
trace,
|
|
56
|
+
Tracer,
|
|
57
|
+
} from '@opentelemetry/api'
|
|
58
|
+
// src/middleware/tracing.ts
|
|
59
|
+
import { Middleware, NodeArgs } from 'flowcraft'
|
|
60
|
+
|
|
61
|
+
export class ObservabilityMiddleware {
|
|
62
|
+
private tracer: Tracer
|
|
63
|
+
private meter: Meter
|
|
64
|
+
private nodeDurationHistogram: any // In real OTel, this is an Instrument
|
|
65
|
+
|
|
66
|
+
constructor() {
|
|
67
|
+
// Get the global tracer and meter from your OTel setup
|
|
68
|
+
this.tracer = trace.getTracer('flowcraft-workflow-tracer')
|
|
69
|
+
this.meter = metrics.getMeter('flowcraft-workflow-meter')
|
|
70
|
+
|
|
71
|
+
// Create a metric to record node execution time
|
|
72
|
+
this.nodeDurationHistogram = this.meter.createHistogram('flowcraft.node.duration', {
|
|
73
|
+
description: 'Duration of Flowcraft node execution',
|
|
74
|
+
unit: 'ms',
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The middleware function itself
|
|
79
|
+
public readonly middleware: Middleware = async (args: NodeArgs, next) => {
|
|
80
|
+
const { name: nodeName } = args
|
|
81
|
+
|
|
82
|
+
// Start a new span for this node execution
|
|
83
|
+
return this.tracer.startActiveSpan(`run ${nodeName}`, async (span: Span) => {
|
|
84
|
+
const startTime = Date.now()
|
|
85
|
+
|
|
86
|
+
span.setAttribute('flowcraft.node.name', nodeName)
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Run the actual node logic
|
|
90
|
+
const action = await next(args)
|
|
91
|
+
|
|
92
|
+
const duration = Date.now() - startTime
|
|
93
|
+
|
|
94
|
+
// Record the duration as a metric
|
|
95
|
+
this.nodeDurationHistogram.record(duration, { 'flowcraft.node.name': nodeName, 'flowcraft.node.status': 'success' })
|
|
96
|
+
|
|
97
|
+
// Add the action to the span and set status to OK
|
|
98
|
+
span.setAttribute('flowcraft.node.action', String(action))
|
|
99
|
+
span.setStatus({ code: SpanStatusCode.OK })
|
|
100
|
+
|
|
101
|
+
return action
|
|
102
|
+
}
|
|
103
|
+
catch (error: any) {
|
|
104
|
+
const duration = Date.now() - startTime
|
|
105
|
+
|
|
106
|
+
// Record the failed duration
|
|
107
|
+
this.nodeDurationHistogram.record(duration, { 'flowcraft.node.name': nodeName, 'flowcraft.node.status': 'error' })
|
|
108
|
+
|
|
109
|
+
// Mark the span as failed and record the error details
|
|
110
|
+
span.setStatus({
|
|
111
|
+
code: SpanStatusCode.ERROR,
|
|
112
|
+
message: error.message,
|
|
113
|
+
})
|
|
114
|
+
span.recordException(error)
|
|
115
|
+
|
|
116
|
+
// Re-throw the error to ensure the workflow's error handling takes over
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
// End the span, ensuring it's always closed
|
|
121
|
+
span.end()
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## How to Use It
|
|
129
|
+
|
|
130
|
+
Using the middleware is straightforward. Instantiate it and apply it to your `Flow` with `.use()`.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { Flow, Node, TypedContext } from 'flowcraft'
|
|
134
|
+
|
|
135
|
+
import { ObservabilityMiddleware } from './middleware/tracing'
|
|
136
|
+
// main.ts
|
|
137
|
+
import './tracing' // Important: Initialize OpenTelemetry SDK first!
|
|
138
|
+
|
|
139
|
+
class GreetNode extends Node {
|
|
140
|
+
async exec() {
|
|
141
|
+
console.log('Hello from GreetNode!')
|
|
142
|
+
// In a real app, an instrumented library like `fetch` would create a child span automatically here.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
class FarewellNode extends Node {
|
|
147
|
+
async exec() {
|
|
148
|
+
console.log('Goodbye from FarewellNode!')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 1. Create an instance of our middleware
|
|
153
|
+
const obsMiddleware = new ObservabilityMiddleware()
|
|
154
|
+
|
|
155
|
+
// 2. Create the flow
|
|
156
|
+
const greetNode = new GreetNode()
|
|
157
|
+
greetNode.next(new FarewellNode())
|
|
158
|
+
const flow = new Flow(greetNode)
|
|
159
|
+
|
|
160
|
+
// 3. Apply the middleware to the flow
|
|
161
|
+
flow.use(obsMiddleware.middleware)
|
|
162
|
+
|
|
163
|
+
// 4. Run it
|
|
164
|
+
await flow.run(new TypedContext())
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## What Happens Now?
|
|
168
|
+
|
|
169
|
+
When you run this code with a configured OpenTelemetry exporter (e.g., sending to Jaeger, Zipkin, or a service like Honeycomb or Datadog), you will get:
|
|
170
|
+
|
|
171
|
+
- **A Trace Waterfall**: You'll see a visual breakdown of your workflow. The `run GreetNode` span will be followed by the `run FarewellNode` span. You can immediately see how long each took and in what order they executed.
|
|
172
|
+
- **Rich Context**: Clicking on a span will show all the attributes we added: the node's name, the action it returned, and any errors that occurred.
|
|
173
|
+
- **Performance Metrics**: You can now build dashboards and alerts based on the `flowcraft.node.duration` metric. For example, you can graph the P95 duration for a specific node or alert if its failure rate (`flowcraft.node.status: 'error'`) spikes.
|
|
174
|
+
|
|
175
|
+
This simple but powerful pattern makes your Flowcraft workflows fully observable, turning them from black boxes into transparent, debuggable, and production-ready systems.
|