Error capture
In production you need to know when flows fail without watching the DevTool. The errorCapture option on createFlowState is an opt-in hook that hands every runtime block failure to a callback you write. Inside that callback you forward the error to whatever observability service you run: Sentry, Datadog, Bugsnag, or your own log pipeline.
This is an error capture service integration seam. The framework defines the shape of the event and calls your callback; it does not depend on any provider SDK. You write the small adapter that maps the event onto your provider's API.
It pairs with error handling. Error handling is about reacting to a failure inside the flow (rescue, retry, structured FlowErrors). Error capture is about getting that failure out to an external system after the fact. They are independent: a failure that a .rescue() recovers still reaches errorCapture, so you can keep flows resilient and still see what went wrong.
The callback
import { createFlowState } from "@flow-state-dev/engine";
import type { ErrorCaptureEvent } from "@flow-state-dev/engine";
const flowstate = createFlowState({
flows: { myFlow },
stores: { default: { primary: inMemoryStores() } },
errorCapture: (event: ErrorCaptureEvent) => {
myErrorService.report(event.error, {
requestId: event.requestId,
flow: event.flowKind,
block: event.blockName,
});
},
});
The callback is read-only. Its return value is ignored, and the runtime calls it for its side effect alone. It may be async. The runtime awaits it inside a guard, so if your callback throws or rejects, the error is logged at warn and swallowed. A broken or slow sink can never change the outcome of the request that triggered it. Keep the callback fast: hand the event to your provider's SDK (which buffers internally) and return.
errorCapture is distinct from onError. onError is an HTTP-level sink that receives { method, path }. errorCapture is block-aware: it receives the failing block's identity and the flow, request, session, and user IDs.
The event
ErrorCaptureEvent is provider-neutral. It carries the normalized error and the correlation IDs your service needs to group and triage; it does not carry Sentry-style tags, severity, or fingerprints. You derive those in your adapter from the fields below.
| Field | Type | Notes |
|---|---|---|
error | FlowError | Normalized error. The raw thrown value is on error.cause. Carries code, retryable, details. |
requestId | string | The failing request. |
flowKind | string | The flow's kind. |
actionName | string | The action that was running. |
userId | string | The caller. |
sessionId | string? | Present for session-bound requests. |
orgId | string? | The organization the request ran in. Optional in the type; set on every event. |
blockName | string? | The failing block (the leaf, not the enclosing sequencer). |
blockKind | "handler" | "generator" | "evaluator" | "sequencer" | "router"? | |
blockInstanceId | string? | Deterministic per block instance. Useful for correlating retries. |
blockPath | string? | Structural path in the execution tree, e.g. root/step[0]. |
attempt | number? | 0-indexed retry attempt, set only under a retry policy. |
scope | "block" | "request" | "sideChain" | "resource"? | Where the error originated. |
transient | boolean? | true for high-frequency transient blocks (poll loops). |
final | true? | Set on the one extra event a request sends when it settles failed. See below. |
The runtime fires the callback once per failed block, identified by the leaf block where the failure actually happened. A failure deep in a nested sequencer reports the inner block, not the outer container. When a block runs under a retry policy, each failed attempt fires once, distinguished by attempt; a block that recovers on a later attempt still reports the attempts that failed. Filter these out in your adapter if you only want terminal failures (see below).
The final event
A request that ends failed sends one more event once it has settled. It carries final: true and scope: "request", the request's IDs, and, when a block threw, that leaf block's identity. It is sent after rescues and retries have run and after the request's failed status is recorded. A request that a retry or a rescue saved never sends one.
So each failing request reaches your callback at least twice: once per failed block, and once more as the final event. Pick the view you want:
// One event per failed request: the request really failed.
errorCapture: (event) => {
if (!event.final) return;
myErrorService.report(event.error, { requestId: event.requestId });
},
// Every failing block, including attempts a retry later fixed. Skip the summary.
errorCapture: (event) => {
if (event.final) return;
myErrorService.report(event.error, { requestId: event.requestId, block: event.blockName });
},
If you forward both, your service sees the same failure twice for every request that ends failed.
Choosing which errors to capture
Because rescued and retried failures fire too, a noisy flow can produce more events than you want in your dashboard. Filter inside the callback:
errorCapture: (event) => {
// Skip transient poll-loop failures and anything the runtime will retry.
if (event.transient || event.error.retryable) return;
myErrorService.report(event.error, { requestId: event.requestId });
},
A reasonable default is to treat retryable: false as an error and retryable: true as a warning, since a retryable failure may still recover. event.error.code ("network_error", "tool_execution_error", and so on) is a good grouping key.
A minimal Sentry adapter
import * as Sentry from "@sentry/node";
errorCapture: (event) => {
// Every block failure is forwarded below; the request's final event would repeat one of them.
if (event.final) return;
Sentry.captureException(event.error, {
level: event.error.retryable ? "warning" : "error",
user: { id: event.userId },
tags: {
flow: event.flowKind,
block: event.blockName ?? "unknown",
code: event.error.code ?? "unknown",
},
extra: { requestId: event.requestId, sessionId: event.sessionId },
});
},
For a full walkthrough including local testing and deployment, see Routing errors to Sentry.
What it does not cover
errorCapture is for runtime block failures. It does not fire for request-level failures that happen before block execution, such as input validation or authentication. Those surface through onError and the HTTP response. Client-side errors are also out of scope; this is a server-side seam.