Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/workflow-lifecycle-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': minor
'workflow': minor
---

Add `registerLifecycleHooks` (exported from `workflow/api`) for registering global `onRunCompleted`/`onRunFailed` handlers that receive the lazily-hydrated `Run` instance (and, for failures, a `WorkflowRunFailedError` with the hydrated cause and error code), enabling centralized reporting (e.g. to Sentry) from `instrumentation.ts`.
3 changes: 3 additions & 0 deletions docs/content/docs/v5/api-reference/workflow-api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ The API package is for access and introspection of workflow data to inspect runs
<Card href="/docs/api-reference/workflow-api/get-run" title="getRun()">
Get workflow run status and metadata without waiting for completion.
</Card>
<Card href="/docs/api-reference/workflow-api/register-lifecycle-hooks" title="registerLifecycleHooks()">
Observe run completions and failures with global handlers.
</Card>
</Cards>

<Callout type="info">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
title: registerLifecycleHooks
description: Register global handlers that observe workflow runs completing or failing.
type: reference
summary: Use registerLifecycleHooks to observe run completions and failures from one central place.
prerequisites:
- /docs/foundations/workflows-and-steps
related:
- /docs/observability/lifecycle-hooks
- /docs/api-reference/workflow-api/get-run
---

Registers global workflow lifecycle handlers, invoked by the runtime on the compute that records a run's terminal transition. Use it for centralized reporting, such as forwarding every failed run to Sentry, without wrapping each workflow body.

Register early in the process lifecycle (in Next.js, `instrumentation.ts`) so handlers exist before the first run finishes. See the [lifecycle hooks guide](/docs/observability/lifecycle-hooks) for semantics and a full Sentry example.

```typescript title="instrumentation.ts" lineNumbers
import { registerLifecycleHooks } from "workflow/api";

export function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
registerLifecycleHooks({
async onRunCompleted({ run }) {
console.log(`Run ${run.runId} completed`);
},
async onRunFailed({ run, error }) {
console.error(`Run ${run.runId} failed (${error.errorCode})`, error.cause);
},
});
}
}
```

## API Signature

### Parameters

<TSDoc
definition={`
import { registerLifecycleHooks } from "workflow/api";
export default registerLifecycleHooks;`}
showSections={["parameters"]}
/>

### Returns

Returns a function that unregisters these hooks.

## Handlers

Both handlers receive the run as a lazily-hydrated [`Run`](/docs/api-reference/workflow-api/get-run) instance. Accessors like `run.workflowName` and `run.returnValue` only fetch from the backend when used.

### `onRunCompleted`

Invoked when a workflow run completes successfully.

| Parameter | Type | Description |
| --- | --- | --- |
| `params.run` | `Run` | The completed run. |

### `onRunFailed`

Invoked when a workflow run fails terminally (after any retries).

| Parameter | Type | Description |
| --- | --- | --- |
| `params.run` | `Run` | The failed run. |
| `params.error` | `WorkflowRunFailedError` | The failure, in the same shape `run.returnValue` rejects with: `error.errorCode` carries the classification (e.g. `USER_ERROR`) and `error.cause` is the hydrated thrown value. |

## Behavior

- Handlers run on the host (full Node.js), never inside the workflow VM. Calling `registerLifecycleHooks` from workflow code throws.
- Handlers are fire-and-forget: they cannot delay or change the run's outcome, and the runtime logs and swallows a throwing handler. On serverless platforms, the runtime keeps the invocation alive with `waitUntil`.
- Handlers fire only on the invocation that wrote the terminal event. Transitions recorded outside your app's compute (e.g. a run cancelled from the CLI or dashboard) do not fire handlers.
- You can register multiple hook sets, and handlers run in registration order.
82 changes: 82 additions & 0 deletions docs/content/docs/v5/observability/lifecycle-hooks.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
title: Lifecycle Hooks
description: Register global handlers that observe workflow runs completing or failing, for centralized reporting to services like Sentry.
type: guide
summary: Observe run completions and failures from a single place with registerLifecycleHooks.
prerequisites:
- /docs/foundations/workflows-and-steps
related:
- /docs/observability
- /docs/observability/tracing
- /docs/errors
---

Lifecycle hooks let you register global handlers that the runtime invokes whenever a workflow run completes or fails. They observe even the failures that never reach a `try/catch` in workflow code, such as a replay timing out or a run exhausting its queue deliveries. The most common use is centralized error reporting, such as forwarding every failed run to a service like Sentry without wrapping each workflow body.

## Registering hooks

Call `registerLifecycleHooks` from `workflow/api` early in your application's lifecycle, so the handlers exist before the first run finishes. In Next.js, [`instrumentation.ts`](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation) is the natural place; in any other app, any module that loads at startup works.

```typescript title="instrumentation.ts" lineNumbers
import { registerLifecycleHooks } from "workflow/api"

export function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
registerLifecycleHooks({
async onRunCompleted({ run }) {
console.log(`Run ${run.runId} completed`)
},
async onRunFailed({ run, error }) {
console.error(
`Run ${run.runId} failed with ${error.errorCode}:`,
error.cause
)
},
})
}
}
```

`registerLifecycleHooks` returns an unregister function. You can register multiple hook sets, and handlers run in registration order.

## Handler parameters

Both handlers receive the [`Run`](/docs/api-reference/workflow-api/get-run) instance for the transitioned run. The `Run` instance hydrates lazily, meaning accessors like `run.workflowName` or `run.returnValue` only fetch from the backend when the handler uses them, so a handler that filters on cheap metadata pays nothing for the runs it ignores.

`onRunFailed` additionally receives the failure as a `WorkflowRunFailedError`, the same shape `run.returnValue` rejects with:

- `error.errorCode`: the failure classification (`USER_ERROR`, `RUNTIME_ERROR`, `MAX_DELIVERIES_EXCEEDED`, and more). See [error codes](/docs/errors) for the full list.
- `error.cause`: the hydrated thrown value, with Error subclass identity, message, stack, and cause chain preserved. Any JavaScript value can be thrown, so this is typed `unknown`.

## Reporting failed runs to Sentry

```typescript title="instrumentation.ts" lineNumbers
import * as Sentry from "@sentry/nextjs"
import { registerLifecycleHooks } from "workflow/api"

export function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
Sentry.init({ dsn: process.env.SENTRY_DSN })

registerLifecycleHooks({
async onRunFailed({ run, error }) {
Sentry.captureException(error.cause ?? error, {
tags: {
workflowRunId: run.runId,
workflowName: await run.workflowName,
errorCode: error.errorCode,
},
})
await Sentry.flush(2000)
},
})
}
}
```

## How handlers behave

- **Host-only.** Handlers run with full Node.js access, never inside the workflow's sandboxed VM. Calling `registerLifecycleHooks` from workflow code throws.
- **Fire-and-forget.** Handlers cannot delay or change the run's outcome. The runtime logs and swallows a throwing handler, and the remaining handlers still run. On serverless platforms, the runtime keeps the invocation alive with `waitUntil` while handlers finish.
- **Fires where the transition is recorded.** Handlers fire on the compute that wrote the terminal event. For a failure, that means after any retries are exhausted, exactly once per run under normal operation. Terminal transitions recorded outside your app's compute do **not** fire handlers. For example, when you cancel a run from the CLI or the Vercel dashboard, the backend writes that transition, so no handler runs. For a complete record of every transition, consume the [event log](/docs/how-it-works/event-sourcing) or set up alerts on the [observability](/docs/observability) surface instead.
- **Register everywhere your workflows run.** The terminal write can happen in any function invocation that processes the run's queue messages, so registration must run at startup in every instance of the app (which `instrumentation.ts` guarantees).
2 changes: 1 addition & 1 deletion docs/content/docs/v5/observability/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "Observability",
"pages": ["tracing", "attributes"]
"pages": ["tracing", "attributes", "lifecycle-hooks"]
}
72 changes: 72 additions & 0 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
Expand Down Expand Up @@ -3666,6 +3666,78 @@
}
);

// Lifecycle hooks (`registerLifecycleHooks`) are registered in the Next.js
// workbenches' instrumentation.ts (see lifecycle-hooks-e2e.ts there). The
// handlers report each lifecycleHookTarget* run's terminal transition by
// resuming the lifecycleHookObserver workflow's hook, a durable channel
// that works even when the terminal write happens on a different instance
// than the one serving these HTTP requests.
describe.skipIf(!isNextJsApp)('lifecycle hooks', () => {
test(
'onRunCompleted receives the Run and can read its return value',
{ timeout: 90_000 },
async () => {
const token = `lifecycle-completed-${Math.random().toString(36).slice(2)}`;

const observer = await start(await e2e('lifecycleHookObserver'), [
token,
]);
await waitForHook(token, { runId: observer.runId });

const target = await start(await e2e('lifecycleHookTargetCompleted'), [
token,
]);
await expect(target.returnValue).resolves.toMatchObject({
outcome: 'completed',
});

// The onRunCompleted handler fetched the target's workflowName and
// returnValue off the lazily-hydrated Run instance, then resumed the
// observer's hook with what it saw.
const payload = await observer.returnValue;
expect(payload).toMatchObject({
observed: 'completed',
runId: target.runId,
workflowName: expect.stringContaining('lifecycleHookTargetCompleted'),
returnedOutcome: 'completed',
});
}
);

test(
'onRunFailed receives the hydrated error with errorCode and cause',
{ timeout: 90_000 },
async () => {
const token = `lifecycle-failed-${Math.random().toString(36).slice(2)}`;

const observer = await start(await e2e('lifecycleHookObserver'), [
token,
]);
await waitForHook(token, { runId: observer.runId });

const target = await start(await e2e('lifecycleHookTargetFailed'), [
token,
]);
const error = await target.returnValue.catch((e: unknown) => e);
expect(WorkflowRunFailedError.is(error)).toBe(true);

// The onRunFailed handler received a WorkflowRunFailedError whose
// errorCode carries the classification and whose cause is the
// hydrated thrown FatalError (name + message preserved).
const payload = await observer.returnValue;
expect(payload).toMatchObject({
observed: 'failed',
runId: target.runId,
errorCode: 'USER_ERROR',
causeName: 'FatalError',
causeMessage: expect.stringContaining(
`lifecycle-hook-target-failed:${token}`
),
});
}
);
});

test(
'hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep',
{ timeout: 90_000 },
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
"types": "./dist/runtime/run.d.ts",
"default": "./dist/runtime/run.js"
},
"./runtime/lifecycle-hooks": {
"types": "./dist/runtime/lifecycle-hooks.d.ts",
"default": "./dist/runtime/lifecycle-hooks.js"
},
"./runtime/start": {
"types": "./dist/runtime/start.d.ts",
"default": "./dist/runtime/start.js"
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ import {
stepDispatchIdempotencyKey,
withHealthCheck,
} from './runtime/helpers.js';
import {
dispatchRunCompletedHooks,
dispatchRunFailedHooks,
} from './runtime/lifecycle-hooks.js';
import {
handleReplayBudgetExhausted,
ReplayBudget,
Expand Down Expand Up @@ -429,6 +433,7 @@ async function recordFatalRunError({
}
throw failErr;
}
dispatchRunFailedHooks(runId, err, errorCode);
}

function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
Expand Down Expand Up @@ -761,6 +766,11 @@ export function workflowEntrypoint(
},
{ requestId }
);
dispatchRunFailedHooks(
runId,
err,
RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED
);
} catch (err) {
if (EntityConflictError.is(err) || RunExpiredError.is(err)) {
// Run already finished, consume the message silently
Expand Down Expand Up @@ -3025,6 +3035,7 @@ export function workflowEntrypoint(
}
throw err;
}
dispatchRunCompletedHooks(runId);

span?.setAttributes({
...Attribute.WorkflowRunStatus('completed'),
Expand Down Expand Up @@ -3233,6 +3244,11 @@ export function workflowEntrypoint(
}
throw failErr;
}
dispatchRunFailedHooks(
runId,
suspensionError,
errorCode
);
span?.setAttributes({
...Attribute.WorkflowRunStatus('failed'),
...Attribute.WorkflowErrorCode(errorCode),
Expand Down Expand Up @@ -4607,6 +4623,7 @@ export function workflowEntrypoint(
}
throw failErr;
}
dispatchRunFailedHooks(runId, terminalError, errorCode);

span?.setAttributes({
...Attribute.WorkflowRunStatus('failed'),
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/runtime/deployment-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { runtimeLogger } from '../logger.js';
import { dehydrateRunError } from '../serialization.js';
import * as Attribute from '../telemetry/semantic-conventions.js';
import { getDeploymentMismatchMaxRetries } from './constants.js';
import { dispatchRunFailedHooks } from './lifecycle-hooks.js';

/** Cap on the re-route backoff, in seconds. */
const MAX_REROUTE_DELAY_SECONDS = 8;
Expand Down Expand Up @@ -212,6 +213,11 @@ export async function guardDeploymentAffinity({
},
{ requestId }
);
dispatchRunFailedHooks(
run.runId,
error,
RUN_ERROR_CODES.DEPLOYMENT_MISMATCH
);
} catch (failError) {
// Run already reached a terminal state (a concurrent writer failed it, or
// it was cancelled/expired) — still stop. Anything else is a transient
Expand Down
Loading
Loading