Skip to content

Commit 5c17dbe

Browse files
mydeaclaude
andauthored
feat(cloudflare): Bootstrap Mastra observability in bundled workers (#24381)
Adds Cloudflare coverage for Mastra — the most common non-Node way users run Mastra — as a Vite + workerd e2e app (`cloudflare-mastra`), and fixes the one thing that stopped it working with zero config. On Cloudflare the Mastra integration couldn't bootstrap Mastra's observability pipeline: it resolves `@mastra/observability` via `createRequire`, which has no on-disk `node_modules` in workerd, so users had to construct and wire up an `Observability` themselves. Now `@sentry/cloudflare/vite` splices a static `import * as ns from '@mastra/observability'` into Sentry's own Mastra integration module — only when the package resolves — and records the namespace on a generic `__SENTRY_ORCHESTRION__.providedModules` marker; the integration reads it there before falling back to `createRequire`. Mastra tracing on Cloudflare now works with no observability config, matching the Node DX. The import lands in Sentry's module (not user code), stays statically analyzable (no lazy `import()`/`createRequire` in the bundle), and no-ops when Mastra isn't used. **Requires `@mastra/observability` to be an app dependency.** The SDK never installs or bundles it — it is Mastra's tracing engine (the span factory), and without it Mastra emits only no-op spans, so there is nothing to capture. This is **not** new or Cloudflare-specific: it is equally required on Node, where the integration bootstraps from the `@mastra/observability` the app already has. On Cloudflare the Vite plugin's injection is additionally gated on it resolving at build time; if it is absent the plugin no-ops and no spans are produced (same net effect as Node). The e2e app mirrors the `node-mastra` assertions (agent/model/tool `gen_ai` spans, tool-error capture, orchestrion `dataloader` nesting, an `http.server` span) against the Cloudflare instrumentation path, where orchestrion runs at build time. Its tool/dataloader assertions run against a live model via the existing `E2E_OPENROUTER_API_KEY`; the `manual-route` test needs no key. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0b170ee commit 5c17dbe

23 files changed

Lines changed: 666 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
node_modules
2+
dist
3+
.wrangler
4+
.dev.vars
5+
pnpm-lock.yaml
6+
*.tsbuildinfo
7+
results.junit.xml
8+
test-results
9+
playwright-report
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "cloudflare-mastra",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite dev",
8+
"build": "vite build",
9+
"preview": "vite preview --port 4111",
10+
"typecheck": "tsc --noEmit",
11+
"clean": "npx rimraf node_modules dist .wrangler pnpm-lock.yaml",
12+
"test:build": "pnpm install && pnpm build",
13+
"test:assert": "pnpm test:prod",
14+
"test:prod": "TEST_ENV=production playwright test"
15+
},
16+
"dependencies": {
17+
"@mastra/core": "~1.65.0",
18+
"@mastra/memory": "~1.28.3",
19+
"@mastra/observability": "~1.17.6",
20+
"@openrouter/ai-sdk-provider": "~3.0.0",
21+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
22+
"ai": "~7.0.97",
23+
"dataloader": "~2.2.3",
24+
"zod": "4.5.4"
25+
},
26+
"devDependencies": {
27+
"@cloudflare/vite-plugin": "1.52.0",
28+
"@cloudflare/workers-types": "^4.20260426.0",
29+
"@playwright/test": "~1.63.0",
30+
"@sentry-internal/test-utils": "link:../../../test-utils",
31+
"typescript": "^5.5.2",
32+
"vite": "7.3.5",
33+
"wrangler": "^4.86.0",
34+
"ws": "^8.18.3"
35+
},
36+
"volta": {
37+
"node": "24.15.0",
38+
"extends": "../../package.json"
39+
},
40+
"sentryTest": {
41+
"optional": true
42+
}
43+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const testEnv = process.env.TEST_ENV;
4+
5+
if (!testEnv) {
6+
throw new Error('No test env defined');
7+
}
8+
9+
const APP_PORT = 4111;
10+
11+
const config = getPlaywrightConfig(
12+
{
13+
startCommand: 'pnpm preview',
14+
port: APP_PORT,
15+
},
16+
// Each test drives a real OpenRouter tool-calling turn (two model calls) and then
17+
// waits for the Mastra spans to flush, which does not fit the default 30s timeout
18+
// when the provider is slow.
19+
{ timeout: 90_000 },
20+
);
21+
22+
export default config;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface Env {
2+
E2E_TEST_DSN: string;
3+
E2E_OPENROUTER_API_KEY: string;
4+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Mastra } from '@mastra/core';
2+
import { InMemoryStore } from '@mastra/core/storage';
3+
import { createWeatherAgent, WEATHER_AGENT } from './mastra/agents/weather-agent';
4+
5+
// This file deliberately contains NO `Sentry.*` calls and no import of
6+
// `@sentry/cloudflare`: `sentryCloudflareVitePlugin()` reads wrangler.toml and wraps
7+
// this default export with `withSentry` at build time, sourcing options from
8+
// `instrument.server.ts` next to this entry.
9+
10+
// Built lazily on the first request, then reused across invocations in the isolate.
11+
//
12+
// Order matters: the Sentry Mastra integration subscribes to `@mastra/core`'s
13+
// build-time diagnostics channels during the injected `withSentry` wrapper's
14+
// (per-request) `init`. The `Mastra` constructor is what fires the channel the
15+
// exporter attaches on, so it must run *after* init — i.e. inside `fetch`, never at
16+
// module top level (which would construct it before any subscriber exists). Importing
17+
// `@mastra/core` at the top is fine: that only evaluates the module (registering the
18+
// channel-subscriber factory on the global marker the wrapper reads), it does not
19+
// construct a `Mastra`.
20+
let mastra: Mastra | undefined;
21+
22+
function getMastra(apiKey: string): Mastra {
23+
if (!mastra) {
24+
mastra = new Mastra({
25+
agents: { [WEATHER_AGENT]: createWeatherAgent(apiKey) },
26+
storage: new InMemoryStore(),
27+
});
28+
}
29+
return mastra;
30+
}
31+
32+
interface GeneratePayload {
33+
message: string;
34+
thread?: string;
35+
resource?: string;
36+
}
37+
38+
export default {
39+
async fetch(request: Request, env: Env): Promise<Response> {
40+
const url = new URL(request.url);
41+
42+
if (url.pathname === '/manual-route') {
43+
return Response.json({ ok: true });
44+
}
45+
46+
if (url.pathname === '/generate' && request.method === 'POST') {
47+
const { message, thread, resource } = (await request.json()) as GeneratePayload;
48+
49+
const agent = getMastra(env.E2E_OPENROUTER_API_KEY).getAgent(WEATHER_AGENT);
50+
51+
// The nested `memory: { thread, resource }` shape is the modern `generate` path —
52+
// it is what makes Mastra stamp the thread id onto the spans (→
53+
// `gen_ai.conversation.id`). Top-level `threadId`/`resourceId` would route to the
54+
// deprecated `generateLegacy` path, which the AI instrumentation does not cover.
55+
const result = await agent.generate(message, {
56+
...(thread ? { memory: { thread, resource: resource ?? 'e2e-user' } } : {}),
57+
});
58+
59+
return Response.json({ text: result.text });
60+
}
61+
62+
return new Response('Not found', { status: 404 });
63+
},
64+
} satisfies ExportedHandler<Env>;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// The auto-instrument plugin (`sentryCloudflareVitePlugin`) picks this file up by
2+
// convention — it sits next to the worker entry named in wrangler's `main` — and
3+
// imports its default export as the options callback for the `withSentry` wrapper it
4+
// injects into the entry at build time.
5+
export default (env: Env) => ({
6+
dsn: env.E2E_TEST_DSN,
7+
environment: 'qa',
8+
tunnel: 'http://localhost:3031/', // proxy server
9+
tracesSampleRate: 1.0,
10+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
2+
import { Agent } from '@mastra/core/agent';
3+
import { InMemoryStore } from '@mastra/core/storage';
4+
import { Memory } from '@mastra/memory';
5+
import { countItemsTool } from '../tools/count-items';
6+
import { failNowTool } from '../tools/fail-now';
7+
import { getWeatherTool } from '../tools/get-weather';
8+
9+
export const WEATHER_AGENT = 'weatherAgent';
10+
11+
export function createWeatherAgent(apiKey: string): Agent {
12+
// Call OpenRouter directly (rather than the default Vercel AI Gateway) so the e2e
13+
// test needs only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY`.
14+
const openrouter = createOpenRouter({ apiKey });
15+
16+
// `InMemoryStore` is a pure-JS store (no libsql/native) so it runs in workerd,
17+
// unlike the `@mastra/libsql` store the Node app uses. Mastra requires a storage
18+
// provider before `generate(..., { memory: { thread, resource } })` is accepted,
19+
// and that thread id is what the Sentry exporter maps to `gen_ai.conversation.id`.
20+
const memory = new Memory({ storage: new InMemoryStore() });
21+
22+
return new Agent({
23+
// `id` (registry key + REST `:agentId`), `name` (used for `gen_ai.agent.name`)
24+
// are kept identical so the generate call and span assertions line up.
25+
id: WEATHER_AGENT,
26+
name: WEATHER_AGENT,
27+
instructions: [
28+
'You are a concise assistant used by an automated end-to-end test.',
29+
'When the user asks about the weather in a place, call the `get_weather` tool for that place and answer in one short sentence using its result.',
30+
'When the user asks you to trigger a failure, call the `fail_now` tool.',
31+
'When the user asks you to count items, call the `count_items` tool with the item names.',
32+
'Do not ask follow-up questions.',
33+
].join('\n'),
34+
model: openrouter('openai/gpt-4o-mini'),
35+
tools: { get_weather: getWeatherTool, fail_now: failNowTool, count_items: countItemsTool },
36+
memory,
37+
});
38+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createTool } from '@mastra/core/tools';
2+
import DataLoader from 'dataloader';
3+
import { z } from 'zod';
4+
5+
// Uses the orchestrion-instrumented `dataloader` inside a tool, to prove the build-time
6+
// orchestrion transform (via `@sentry/cloudflare/vite`) works for a non-Mastra package
7+
// driven through the agent's tool-call flow.
8+
export const countItemsTool = createTool({
9+
id: 'count_items',
10+
description: 'Count the number of letters in each given name. Call this when asked to count items.',
11+
inputSchema: z.object({ names: z.array(z.string()).min(1) }),
12+
outputSchema: z.object({ counts: z.array(z.number()) }),
13+
async execute(inputData) {
14+
const loader = new DataLoader<string, number>(async keys => keys.map(key => key.length));
15+
const counts = await Promise.all(inputData.names.map(name => loader.load(name)));
16+
return { counts };
17+
},
18+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { createTool } from '@mastra/core/tools';
2+
import { z } from 'zod';
3+
4+
export const failNowTool = createTool({
5+
id: 'fail_now',
6+
description: 'Always throws an error. Call this when the user asks to trigger a failure.',
7+
inputSchema: z.object({}),
8+
outputSchema: z.object({}),
9+
async execute() {
10+
throw new Error('Intentional Mastra tool failure');
11+
},
12+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { createTool } from '@mastra/core/tools';
2+
import { z } from 'zod';
3+
4+
export const getWeatherTool = createTool({
5+
id: 'get_weather',
6+
description: 'Get the current weather for a city.',
7+
inputSchema: z.object({ city: z.string().min(1) }),
8+
outputSchema: z.object({
9+
city: z.string(),
10+
condition: z.string(),
11+
temperatureC: z.number(),
12+
}),
13+
async execute(inputData) {
14+
return { city: inputData.city, condition: 'Sunny', temperatureC: 22 };
15+
},
16+
});

0 commit comments

Comments
 (0)