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
6 changes: 6 additions & 0 deletions .changeset/analytics-attribute-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/world': minor
'@workflow/world-vercel': minor
---

Add `analytics.attributes.list()` for attribute key discovery, and an `attributes` key=value filter to `analytics.runs.list()`.
2 changes: 1 addition & 1 deletion docs/content/docs/v5/api-reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ All the functions and primitives that come with Workflow SDK by package.
API reference for runtime functions from the `workflow/api` package.
</Card>
<Card title="workflow/runtime" href="/docs/api-reference/workflow-runtime">
Runtime functions for resolving the World instance and the low-level World SDK.
Runtime functions for resolving the World instance and the low-level World SDK, including storage and analytics queries.
</Card>
<Card title="workflow/observability" href="/docs/api-reference/workflow-observability">
Utilities to hydrate step I/O, parse display names, and decrypt workflow data.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The runtime package provides low-level access to the workflow runtime — resolv
Async: resolve the World instance for storage, queuing, and streaming backends.
</Card>
<Card href="/docs/api-reference/workflow-runtime/world" title="World SDK">
Low-level API for inspecting runs, steps, events, hooks, streams, and queues.
Low-level API for inspecting runs, steps, events, hooks, streams, and queues, plus metadata-only analytics with attribute search.
</Card>
</Cards>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
title: Analytics
description: Metadata-only read APIs for runs, steps, events, hooks, waits, and attributes, backed by the observability pipeline.
type: reference
summary: "Interfaces: world.analytics.runs, .attributes, .steps, .events, .hooks, .waits. Metadata-only listings with plan-based lookback windows; filter runs by attribute key=value."
prerequisites:
- /docs/api-reference/workflow-runtime/get-world
related:
- /docs/api-reference/workflow-runtime/world/storage
- /docs/observability/attributes
keywords:
- world.analytics
- analytics.runs
- analytics.attributes
- attribute filter
- listValues
- lookback window
- observability-upgrade-required
- pageInfo
- metadata-only
---

`world.analytics` is an optional, read-only namespace for observability surfaces — dashboards, CLIs, and admin tools that list large numbers of runs without touching payload data.

It differs from [Storage](/docs/api-reference/workflow-runtime/world/storage) in three ways:

- **Metadata only.** Results never include run input/output, step data, or hook tokens. There is no `resolveData` option.
- **Served from the observability pipeline.** On Vercel, queries hit a ClickHouse-backed analytics store instead of the runtime database, so large listings do not compete with workflow execution. Data is ingested asynchronously and may trail the live state by a few seconds.
- **Plan-bounded lookback.** Listings only cover the observability retention window for your plan (up to 30 days). Every page includes a `pageInfo` block describing the current window, and requesting an older window fails with an `observability-upgrade-required` error.

The namespace is optional — worlds that don't implement it (such as the local development world) leave it `undefined`, so feature-detect before use:

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld();
if (world.analytics) { // [!code highlight]
const page = await world.analytics.runs.list();
}
```

---

## analytics.runs

### runs.list()

List runs with metadata, current status, and attributes. Without an explicit time window, the listing defaults to the trailing 24 hours; pass `startTime`/`endTime` to reach older runs within your plan window.

```typescript lineNumbers
const page = await world.analytics.runs.list({
workflowName: "orderWorkflow",
status: "failed",
attributes: { source: "checkout" }, // [!code highlight]
pagination: { limit: 50, sortOrder: "desc" },
});
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `params.workflowName` | `string` | Filter to one workflow |
| `params.status` | `string` | `pending`, `running`, `completed`, `failed`, or `cancelled` |
| `params.startTime` / `params.endTime` | `string` | ISO 8601 window; must be provided together |
| `params.attributes` | `Record<string, string>` | Only return runs whose latest attributes match every pair (up to 8) |
| `params.pagination` | `PaginationOptions` | Cursor pagination |

**Returns:** `PaginatedResponse<AnalyticsRun>` — each run includes `runId`, `status`, `workflowName`, `deploymentId`, `attributes`, and lifecycle timestamps.

Attribute matching is latest-write-wins: a run whose attribute moved from `"v1"` to `"v2"` no longer matches `{ key: "v1" }`. Reserved `$`-prefixed keys may be used in filters even though user code cannot write them.

### runs.get()

Fetch one run by ID. Point lookups search the full plan window, not just the trailing 24 hours.

```typescript lineNumbers
const run = await world.analytics.runs.get(runId);
```

---

## analytics.attributes

Discover which [attributes](/docs/observability/attributes) exist on your runs — for example to build filter dropdowns over arbitrary user-defined keys.

### attributes.list()

List the distinct attribute keys observed on runs in the window, ordered alphabetically.

```typescript lineNumbers
const page = await world.analytics.attributes.list({ // [!code highlight]
workflowName: "orderWorkflow",
});
for (const { key, runCount, lastSeenAt } of page.data) {
console.log(key, runCount, lastSeenAt);
}
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `params.workflowName` | `string` | Only count runs of one workflow |
| `params.startTime` / `params.endTime` | `string` | ISO 8601 window; must be provided together |
| `params.pagination` | `PaginationOptions` | Cursor pagination |

**Returns:** `PaginatedResponse<AnalyticsAttributeKey>` — `{ key, runCount, firstSeenAt, lastSeenAt }`

---

## analytics.steps, analytics.events, analytics.hooks, analytics.waits

Run-scoped listings mirroring their [Storage](/docs/api-reference/workflow-runtime/world/storage) counterparts, minus payload data:

```typescript lineNumbers
const steps = await world.analytics.steps.list({ runId });
const events = await world.analytics.events.list({ runId, eventType: "step_failed" });
const related = await world.analytics.events.listByCorrelationId({ correlationId });
const hooks = await world.analytics.hooks.list({ runId });
const waits = await world.analytics.waits.list({ runId, status: "waiting" });
```

Each namespace also has a `get()` for point lookups (`steps.get(runId, stepId)`, `events.get(runId, eventId)`, `hooks.get(hookId)`, `waits.get(runId, waitId)`). Hook listings never include the hook token — resolve it separately through the runtime APIs if you need to deliver a payload.

---

## Lookback windows and pageInfo

Every paginated response carries `pageInfo` describing the window the query was allowed to scan:

{/* @skip-typecheck: shape illustration, not runnable code */}
```typescript
{
currentLookbackDays: 2, // what your plan allows today
maxLookbackDays: 30, // ceiling with Observability Plus
currentWindowStart: Date,
maxWindowStart: Date,
upgradeAvailable: true, // more history exists behind the plan gate
}
```

Requests for a window older than `currentWindowStart` fail with an `observability-upgrade-required` error; windows older than `maxWindowStart` return not-found. Use `pageInfo` to size date pickers and to decide whether to surface an upgrade prompt.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: World SDK
description: Low-level API for inspecting and managing workflow runs, steps, events, hooks, streams, and queues.
description: Low-level API for inspecting and managing workflow runs, steps, events, hooks, streams, and queues, with an analytics namespace for metadata listings and attribute search.
type: overview
summary: Access workflow infrastructure via await getWorld() for building observability dashboards, admin tools, and custom integrations.
prerequisites:
Expand All @@ -14,7 +14,7 @@ keywords:
- workflow management
---

The World SDK provides direct access to workflow infrastructure — runs, steps, events, hooks, streams, and queues. Use it to build observability dashboards, admin panels, debugging tools, and custom workflow management logic.
The World SDK provides direct access to workflow infrastructure — runs, steps, events, hooks, streams, and queues — plus a metadata-only [Analytics](/docs/api-reference/workflow-runtime/world/analytics) namespace with attribute discovery and filtering. Use it to build observability dashboards, admin panels, debugging tools, and custom workflow management logic.

```typescript lineNumbers
import { getWorld } from "workflow/runtime";
Expand All @@ -28,6 +28,9 @@ const world = await getWorld(); // [!code highlight]
<Card href="/docs/api-reference/workflow-runtime/world/storage" title="Storage">
Query runs, steps, hooks, and the underlying event log.
</Card>
<Card href="/docs/api-reference/workflow-runtime/world/analytics" title="Analytics">
Metadata-only listings with attribute discovery and filtering, built for observability dashboards.
</Card>
<Card href="/docs/api-reference/workflow-runtime/world/streams" title="Streams">
Read, write, and manage real-time data streams for workflow runs.
</Card>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "World SDK",
"pages": ["storage", "streams", "queue"]
"pages": ["storage", "analytics", "streams", "queue"]
}
28 changes: 25 additions & 3 deletions docs/content/docs/v5/observability/attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ related:
- /docs/api-reference/workflow-errors/workflow-world-error
---

[`setAttributes`](/docs/api-reference/workflow/set-attributes) attaches plaintext string metadata to the current workflow run. These attributes are displayed in observability CLI/UI.
In the future, you'll be able to search and filter runs by attributes.
[`setAttributes`](/docs/api-reference/workflow/set-attributes) attaches plaintext string metadata to the current workflow run. These attributes are displayed in observability CLI/UI, and can be used to search and filter runs through the [Analytics API](/docs/api-reference/workflow-runtime/world/analytics).

You can also seed any attributes directly when starting a run:

Expand Down Expand Up @@ -72,10 +71,33 @@ Expanding an `attr_set` event — in the run sidebar or the Events tab — shows

![Expanded attr_set events showing changes and the writer](/screenshots/attributes/run-details-attr-set-events.png)

## Searching and filtering by attributes

The [Analytics API](/docs/api-reference/workflow-runtime/world/analytics) can discover which attribute keys exist and filter run listings by them. The `analytics` namespace is optional on `World` — feature-detect it before use; it is absent on local, Postgres, and other custom Worlds:

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld();
if (!world.analytics) {
throw new Error("This World does not support analytics queries"); // [!code highlight]
}

// Which attribute keys exist, and on how many runs?
const keys = await world.analytics.attributes.list();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

World.analytics is optional, but this snippet dereferences it without the runtime check claimed by the @skip-typecheck annotation. It will throw in local/Postgres/custom Worlds where analytics is absent. Please feature-detect here (as the new Analytics reference page does) before calling attributes.list() or runs.list().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e19f7b3. The snippet now feature-detects: if (!world.analytics) { throw new Error(...) } before any analytics call, and the prose above it states the namespace is optional and absent on local/Postgres/custom Worlds. Since the guard makes the block genuinely type-safe, I also removed the @skip-typecheck annotation — the block is now typechecked by test:docs (skipped count dropped by one, all green).


// List runs whose latest attributes match every pair
const stuck = await world.analytics.runs.list({
attributes: { phase: "received" }, // [!code highlight]
});
```

Matching is latest-write-wins: once the run above writes `phase: "complete"`, it stops matching `phase: "received"`.

## Behavior

- Attributes require a World implementing spec version 4 or later.
- Writes from workflow and step bodies append native `attr_set` events and immediately materialize `run.attributes`.
- Storage errors surface rather than being silently ignored: transient errors on workflow-body writes are retried, and a write the World rejects as invalid (for example, exceeding the per-run attribute cap across multiple calls) fails the run with the validation error.
- Step-body storage errors throw from `setAttributes` like any other step-side network write. Catch the error inside the step if the attribute is best-effort.
- Reading and querying attributes is not available yet. A query API is planned.
- Reading and querying: each run's current attributes are returned on the run objects from the [Storage](/docs/api-reference/workflow-runtime/world/storage) and [Analytics](/docs/api-reference/workflow-runtime/world/analytics) APIs, and the Analytics API supports discovering attribute keys and filtering run listings by key=value pairs (see [Searching and filtering by attributes](#searching-and-filtering-by-attributes)). On Worlds without the optional `analytics` namespace, attributes are readable on run objects but not searchable.
29 changes: 29 additions & 0 deletions packages/docs-typecheck/src/docs-globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,34 @@ declare global {
name: string
) => Promise<{ tailIndex: number; done: boolean }>;
};
// Metadata-only analytics namespace (optional on World; liberal here
// so reference snippets can call it without a feature-detect guard)
analytics: {
runs: {
get: (...args: any[]) => Promise<any>;
list: (...args: any[]) => Promise<any>;
};
attributes: {
list: (...args: any[]) => Promise<any>;
};
steps: {
get: (...args: any[]) => Promise<any>;
list: (...args: any[]) => Promise<any>;
};
events: {
get: (...args: any[]) => Promise<any>;
list: (...args: any[]) => Promise<any>;
listByCorrelationId: (...args: any[]) => Promise<any>;
};
hooks: {
get: (...args: any[]) => Promise<any>;
list: (...args: any[]) => Promise<any>;
};
waits: {
get: (...args: any[]) => Promise<any>;
list: (...args: any[]) => Promise<any>;
};
};
// Queue methods live directly on world (Queue interface)
getDeploymentId: (...args: any[]) => Promise<any>;
queue: (...args: any[]) => Promise<any>;
Expand All @@ -243,6 +271,7 @@ declare global {
const streamName: string;
const hookId: string;
const eventId: string;
const correlationId: string;
const cursor: string | undefined;
const name: string;
const chunk: any;
Expand Down
66 changes: 66 additions & 0 deletions packages/world-vercel/src/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const state = vi.hoisted(() => ({
makeRequest: vi.fn(),
}));

vi.mock('./utils.js', () => ({
makeRequest: state.makeRequest,
}));

const { createAnalytics } = await import('./analytics.js');

describe('createAnalytics attributes', () => {
beforeEach(() => {
state.makeRequest.mockReset();
state.makeRequest.mockResolvedValue({
data: [],
cursor: null,
hasMore: false,
});
});

it('serializes attribute filters on runs.list as a JSON query param', async () => {
const analytics = createAnalytics();
await analytics.runs.list({
attributes: { team: 'growth', '$eve.type': 'session' },
});

const { endpoint } = state.makeRequest.mock.calls[0][0];
expect(endpoint).toBe(
`/v2/analytics/runs?attributes=${encodeURIComponent(
JSON.stringify({ team: 'growth', '$eve.type': 'session' })
)}`
);
});

it('omits the attributes param when the filter object is empty', async () => {
const analytics = createAnalytics();
await analytics.runs.list({ attributes: {} });

const { endpoint } = state.makeRequest.mock.calls[0][0];
expect(endpoint).toBe('/v2/analytics/runs');
});

it('lists attribute keys with filters and pagination', async () => {
const analytics = createAnalytics();
await analytics.attributes.list({
workflowName: 'daily-report',
startTime: '2026-06-20T00:00:00.000Z',
endTime: '2026-06-21T00:00:00.000Z',
pagination: { limit: 25, cursor: 'abc', sortOrder: 'asc' },
});

const { endpoint } = state.makeRequest.mock.calls[0][0];
const url = new URL(endpoint, 'https://example.test');
expect(url.pathname).toBe('/v2/analytics/attributes');
expect(Object.fromEntries(url.searchParams)).toEqual({
workflowName: 'daily-report',
startTime: '2026-06-20T00:00:00.000Z',
endTime: '2026-06-21T00:00:00.000Z',
limit: '25',
cursor: 'abc',
sortOrder: 'asc',
});
});
});
Loading
Loading