Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/calm-clocks-yield.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/workflow-core': patch
'@tanstack/workflow-runtime': patch
---

Add runtime deadlines, automatic cooperative yielding at durable boundaries,
and deadline helpers under `ctx.runtime`. Timer wake identities now include the
durable operation ID so sequential waits at the same timestamp resume safely.
6 changes: 6 additions & 0 deletions .changeset/metal-shoes-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/workflow-core': patch
'@tanstack/workflow-runtime': patch
---

Add first-class OpenTelemetry tracing for workflow runtime operations, durable store calls, and fresh step execution.
63 changes: 63 additions & 0 deletions docs/api/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { defineWorkflowRuntime } from '@tanstack/workflow-runtime'
const runtime = defineWorkflowRuntime({
store,
defaultLeaseMs: 30_000,
telemetry: {
spanNamePrefix: 'tanstack.workflow',
},
workflows: {
checkout: {
load: async () => checkoutWorkflow,
Expand All @@ -31,6 +34,43 @@ const runtime = defineWorkflowRuntime({
})
```

### Telemetry

Workflow emits OpenTelemetry traces through `@opentelemetry/api`. Applications
own SDK/exporter setup. If no SDK is configured, spans are no-ops.

```ts
const runtime = defineWorkflowRuntime({
store,
workflows,
telemetry: {
attributes: ({ workflowId, runId }) => ({
'app.workflow': workflowId ?? 'unknown',
'app.run': runId ?? 'unknown',
}),
mapStepMeta: (meta) => ({
'app.step_name': String(meta.name),
}),
},
})
```

Set `telemetry: false` or `telemetry: { enabled: false }` to disable tracing.

`WorkflowTelemetryOptions`:

| Option | Purpose |
| --- | --- |
| `enabled` | Set false to disable tracing. |
| `tracer` | Custom OpenTelemetry `Tracer`. Defaults to the global tracer provider. |
| `spanNamePrefix` | Prefix for Workflow span names. Defaults to `tanstack.workflow`. |
| `attributes` | Safe shared attributes or a function that returns them per span. |
| `recordExceptions` | Whether to call `span.recordException`. Defaults to true. |
| `mapStepMeta` | Optional mapper for safe `ctx.step(..., { meta })` attributes. |

Workflow never records workflow input, output, signal payloads, step results, or
raw step metadata as attributes by default.

### Workflow registration

| Field | Purpose |
Expand All @@ -40,6 +80,24 @@ const runtime = defineWorkflowRuntime({
| `previousVersions` | Loaders for old versions that still have paused runs. |
| `schedules` | Registered recurring schedules for this workflow. |

## Runtime deadlines

Every runtime method that can drive workflow code accepts the same budget
options:

| Option | Purpose |
| --- | --- |
| `deadline` | Absolute wall-clock deadline in UTC milliseconds. |
| `maxDurationMs` | Maximum wall-clock duration for this runtime call. |
| `minYieldRemainingMs` | Budget reserved before fresh durable work. Defaults to 1000ms. |

When both deadline forms are present, the earlier deadline wins. The runtime
checks the budget before fresh `step`, `now`, and `uuid` work. A low budget
pauses the run on a durable timer, releases its lease, and lets a later sweep
resume it. Existing `sleep`, `waitForEvent`, and `approve` calls already pause.

Step timeouts remain per-attempt limits and do not use the runtime deadline.

## `runtime.startRun`

Starts or attaches to a run ID through the runtime store.
Expand All @@ -63,6 +121,9 @@ Options:
| `runId` | Stable run identifier. |
| `input` | Workflow input for new runs. |
| `now` | Override current time for tests or host events. |
| `deadline` | Absolute wall-clock deadline in UTC milliseconds. |
| `maxDurationMs` | Maximum wall-clock duration for this call. |
| `minYieldRemainingMs` | Budget reserved before fresh durable work. |
| `leaseOwner` | Worker identity used while executing. |
| `leaseMs` | Lease duration. |
| `threadId` | Optional core engine thread ID. |
Expand Down Expand Up @@ -124,7 +185,9 @@ Options:
| `limit` | Backward-compatible shared limit for schedules and timers. |
| `maxScheduledRuns` | Maximum due schedule buckets to start. |
| `maxTimers` | Maximum due timers to resume. |
| `deadline` | Absolute wall-clock deadline in UTC milliseconds. |
| `maxDurationMs` | Wall-clock budget for this sweep. |
| `minYieldRemainingMs` | Stops claims and fresh durable work inside this margin. |
| `leaseOwner` | Worker identity for claimed work. |
| `leaseMs` | Lease duration. |
| `includeEvents` | Whether to retain emitted events. Sweeps usually set false. |
Expand Down
26 changes: 26 additions & 0 deletions docs/concepts/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,32 @@ ctx.emit('progress', { step: 3, of: 10 })

Run-level `AbortSignal`. Already-aborted state propagates to `step` fns via `stepCtx.signal`.

## `ctx.runtime`

Runtime deadline metadata and cooperative yielding:

```ts
ctx.runtime.deadline
ctx.runtime.timeRemaining()
ctx.runtime.shouldYield({ minRemainingMs: 2_000 })
await ctx.runtime.yield({ reason: 'host deadline' })
```

The runtime automatically yields before fresh durable work when its budget is
low, so queue loops do not need arbitrary batch sizes:

```ts
for (let index = 0; ; index++) {
const item = await ctx.step(`take-${index}`, takeNextItem)
if (!item) break
await ctx.step(`process-${item.id}`, () => processItem(item))
}
```

`stepCtx.runtime` exposes the deadline, remaining time, and `shouldYield` for
long-running step logic. A step cannot yield midway because partial step work is
not a durable checkpoint. Step timeouts remain independent.

```ts
await ctx.step('long-fetch', (stepCtx) =>
fetch(url, { signal: stepCtx.signal }),
Expand Down
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
"label": "Persistence",
"to": "guide/persistence"
},
{
"label": "Observability",
"to": "guide/observability"
},
{
"label": "Deployment",
"to": "guide/deployment"
Expand Down
33 changes: 33 additions & 0 deletions docs/cookbook/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,39 @@ export const workflowRuntime = defineWorkflowRuntime({
})
```

## Add OpenTelemetry tracing

Configure your app's OpenTelemetry SDK in the host-specific instrumentation
entrypoint, then create the workflow runtime normally. Workflow emits spans
through `@opentelemetry/api` and does not configure exporters.

```ts
export const workflowRuntime = defineWorkflowRuntime({
store,
workflows,
telemetry: {
attributes: ({ workflowId }) => ({
'app.workflow': workflowId ?? 'unknown',
}),
},
})
```

For Railway or long-running Node services, initialize the SDK before importing
the runtime. For Vercel, Netlify, and Cloudflare, use the provider's supported
instrumentation/bootstrap entrypoint. Host adapters inherit tracing because they
call `runtime.sweep()`.

Disable tracing for tests or sensitive environments:

```ts
export const workflowRuntime = defineWorkflowRuntime({
store,
workflows,
telemetry: false,
})
```

## Apply workflow store migrations

Workflow owns its durable store schema. Apply the package-owned SQL migration
Expand Down
27 changes: 25 additions & 2 deletions docs/guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ This guide walks through the production shape:
1. Define a workflow with `@tanstack/workflow-core`.
2. Put it behind a runtime with `@tanstack/workflow-runtime`.
3. Persist executions in a durable store.
4. Wake due timers and schedules with a host cron, scheduled function, or worker.
5. Deploy the same workflow code on Cloudflare, Railway, Netlify, Node, Vercel,
4. Observe runtime, store, and step execution with OpenTelemetry.
5. Wake due timers and schedules with a host cron, scheduled function, or worker.
6. Deploy the same workflow code on Cloudflare, Railway, Netlify, Node, Vercel,
or your own infrastructure.

## Package map
Expand Down Expand Up @@ -162,6 +163,28 @@ export const workflowRuntime = defineWorkflowRuntime({
Use the in-memory store for tests and demos only. Production deployments need a
store that can persist executions across invocations.

## Add OpenTelemetry

TanStack Workflow emits OpenTelemetry traces through `@opentelemetry/api`.
Applications own SDK and exporter setup; Workflow emits no-op spans until an SDK
is configured.

```ts
export const workflowRuntime = defineWorkflowRuntime({
store,
workflows,
telemetry: {
attributes: ({ workflowId }) => ({
'app.workflow': workflowId ?? 'unknown',
}),
},
})
```

Workflow traces runtime operations, store calls, sweeps, and fresh `ctx.step`
executions. It does not record workflow input, output, signal payloads, step
results, or raw metadata as attributes by default.

## Use Postgres for durability

The first production-style store is Drizzle/Postgres:
Expand Down
110 changes: 110 additions & 0 deletions docs/guide/observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
id: observability
title: Observability
---

# Observability

TanStack Workflow emits OpenTelemetry traces from the runtime and core engine.
The packages depend only on `@opentelemetry/api`; your application owns the OTel
SDK, exporter, collector, and provider setup.

If no OpenTelemetry SDK is configured, tracing is a no-op.

## What is traced

The runtime creates spans for:

- `tanstack.workflow.start_run`
- `tanstack.workflow.deliver_signal`
- `tanstack.workflow.deliver_approval`
- `tanstack.workflow.sweep`
- `tanstack.workflow.drive_run`

Store calls made by the runtime are traced as child spans, such as
`tanstack.workflow.store.claim_run`,
`tanstack.workflow.store.append_events`, and
`tanstack.workflow.store.claim_due_timers`.

Fresh `ctx.step` executions create `tanstack.workflow.step` spans. Replayed
steps do not create fresh step execution spans because no user step code ran.

## Privacy defaults

Workflow tracing intentionally does not record workflow input, workflow output,
signal payloads, step results, or arbitrary step metadata as attributes.

Built-in attributes are stable identifiers and counts:

- `tanstack.workflow.workflow_id`
- `tanstack.workflow.workflow_version`
- `tanstack.workflow.run_id`
- `tanstack.workflow.operation`
- `tanstack.workflow.result_kind`
- `tanstack.workflow.step_id`
- `tanstack.workflow.signal_name`
- `tanstack.workflow.schedule_id`
- `tanstack.workflow.bucket_id`
- `tanstack.workflow.lease_owner`
- `tanstack.workflow.event_count`
- `tanstack.workflow.events_truncated`

If you want to add safe application attributes, pass `telemetry.attributes` or
`telemetry.mapStepMeta`.

## Configure tracing

Initialize OpenTelemetry in your app before Workflow code runs. Then create the
runtime normally:

```ts
import { defineWorkflowRuntime } from '@tanstack/workflow-runtime'

export const workflowRuntime = defineWorkflowRuntime({
store,
workflows,
})
```

Workflow uses the global OpenTelemetry tracer provider by default.

To customize Workflow tracing:

```ts
export const workflowRuntime = defineWorkflowRuntime({
store,
workflows,
telemetry: {
spanNamePrefix: 'tanstack.workflow',
attributes: ({ workflowId }) => ({
'app.workflow_id': workflowId ?? 'unknown',
}),
mapStepMeta: (meta) => ({
'app.step_name': String(meta.name),
}),
},
})
```

Only use `mapStepMeta` for metadata you know is safe to export. Workflow does
not export step metadata by default.

Disable tracing for a runtime:

```ts
export const workflowRuntime = defineWorkflowRuntime({
store,
workflows,
telemetry: false,
})
```

## Deployment notes

Host adapters do not need separate tracing configuration. Netlify, Vercel,
Cloudflare, and Railway helpers call `runtime.sweep()`, so they inherit the
runtime spans automatically.

For serverless platforms, initialize the OpenTelemetry SDK in the platform's
recommended instrumentation entrypoint. Workflow only emits spans through the
OpenTelemetry API; exporter flushing and process lifecycle are app concerns.
2 changes: 1 addition & 1 deletion packages/workflow-cloudflare/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tanstack/workflow-cloudflare",
"version": "0.0.1",
"version": "0.0.2",
"description": "Cloudflare host adapter for TanStack Workflow runtimes.",
"author": "Tanner Linsley",
"license": "MIT",
Expand Down
4 changes: 4 additions & 0 deletions packages/workflow-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
"@standard-schema/spec": "^1.1.0"
},
"devDependencies": {
"@opentelemetry/api": "^1.9.0",
"zod": "^4.2.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0"
}
}
Loading
Loading