diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.npmrc b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json new file mode 100644 index 000000000000..686e747422fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -0,0 +1,41 @@ +{ + "name": "cloudflare-mcp", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "build": "wrangler deploy --dry-run", + "test": "vitest --run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.22.0", + "@sentry/cloudflare": "latest || *", + "agents": "^0.2.23", + "zod": "^3.25.76" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.8.19", + "@cloudflare/workers-types": "^4.20240725.0", + "@playwright/test": "~1.50.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.5.2", + "vitest": "~3.2.0", + "wrangler": "^4.23.0", + "ws": "^8.18.3" + }, + "volta": { + "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "strip-literal": "~2.0.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/playwright.config.ts new file mode 100644 index 000000000000..73abbd951b90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/playwright.config.ts @@ -0,0 +1,22 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm dev --port ${APP_PORT}`, + port: APP_PORT, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/env.d.ts new file mode 100644 index 000000000000..1701ed9f621a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/env.d.ts @@ -0,0 +1,7 @@ +// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time) +// by running `wrangler types` + +interface Env { + E2E_TEST_DSN: ''; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts new file mode 100644 index 000000000000..5528ccffd3de --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts @@ -0,0 +1,83 @@ +/** + * Welcome to Cloudflare Workers! This is your first worker. + * + * - Run `npm run dev` in your terminal to start a development server + * - Open a browser tab at http://localhost:8787/ to see your worker in action + * - Run `npm run deploy` to publish your worker + * + * Bind resources to your worker in `wrangler.toml`. After adding bindings, a type definition for the + * `Env` object can be regenerated with `npm run cf-typegen`. + * + * Learn more at https://developers.cloudflare.com/workers/ + */ +import * as Sentry from '@sentry/cloudflare'; +import { createMcpHandler } from 'agents/mcp'; +import * as z from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + debug: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + }), + { + async fetch(request, env, ctx) { + const server = new McpServer({ + name: 'cloudflare-mcp', + version: '1.0.0', + }); + + const span = Sentry.getActiveSpan(); + + if (span) { + span.setAttribute('mcp.server.extra', ' /|\ ^._.^ /|\ '); + } + + server.registerTool( + 'my-tool', + { + title: 'My Tool', + description: 'My Tool Description', + inputSchema: { + message: z.string(), + }, + }, + async ({ message }) => { + const span = Sentry.getActiveSpan(); + + // simulate a long running tool + await new Promise(resolve => setTimeout(resolve, 500)); + + if (span) { + span.setAttribute('mcp.tool.name', 'my-tool'); + span.setAttribute('mcp.tool.extra', 'ƸӜƷ'); + span.setAttribute('mcp.tool.input', JSON.stringify({ message })); + } + + return { + content: [ + { + type: 'text' as const, + text: `Tool my-tool: ${message}`, + }, + ], + }; + }, + ); + + const handler = createMcpHandler(Sentry.wrapMcpServerWithSentry(server), { + route: '/mcp', + }); + + return handler(request, env, ctx); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/start-event-proxy.mjs new file mode 100644 index 000000000000..da5101c5275f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-mcp', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts new file mode 100644 index 000000000000..8ce8b693499e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; + +test('sends spans for MCP tool calls', async ({ baseURL }) => { + const spanRequestWaiter = waitForRequest('cloudflare-mcp', event => { + const transaction = event.envelope[1][0][1]; + return typeof transaction !== 'string' && 'transaction' in transaction && transaction.transaction === 'POST /mcp'; + }); + + const spanMcpWaiter = waitForRequest('cloudflare-mcp', event => { + const transaction = event.envelope[1][0][1]; + return ( + typeof transaction !== 'string' && + 'transaction' in transaction && + transaction.transaction === 'tools/call my-tool' + ); + }); + + const response = await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'my-tool', + arguments: { + message: 'ʕっ•ᴥ•ʔっ', + }, + }, + }), + }); + + expect(response.status).toBe(200); + + const requestData = await spanRequestWaiter; + const mcpData = await spanMcpWaiter; + + const requestEvent = requestData.envelope[1][0][1]; + const mcpEvent = mcpData.envelope[1][0][1]; + + // Check that the events have contexts + // this is for TypeScript type safety + if ( + typeof mcpEvent === 'string' || + !('contexts' in mcpEvent) || + typeof requestEvent === 'string' || + !('contexts' in requestEvent) + ) { + throw new Error("Events don't have contexts"); + } + + expect(mcpEvent.contexts?.trace?.trace_id).toBe((mcpData.envelope[0].trace as any).trace_id); + expect(requestData.envelope[0].event_id).not.toBe(mcpData.envelope[0].event_id); + + expect(requestEvent.contexts?.trace).toEqual({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + 'sentry.op': 'http.server', + 'sentry.source': 'url', + 'sentry.sample_rate': 1, + 'http.request.method': 'POST', + 'url.path': '/mcp', + 'url.full': 'http://localhost:38787/mcp', + 'url.port': '38787', + 'url.scheme': 'http:', + 'server.address': 'localhost', + 'http.request.body.size': 120, + 'user_agent.original': 'node', + 'http.request.header.content_type': 'application/json', + 'network.protocol.name': 'HTTP/1.1', + 'mcp.server.extra': ' /|\ ^._.^ /|\ ', + 'http.response.status_code': 200, + }), + op: 'http.server', + status: 'ok', + origin: 'auto.http.cloudflare', + }); + + expect(mcpEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + parent_span_id: requestEvent.contexts?.trace?.span_id, + span_id: expect.any(String), + op: 'mcp.server', + origin: 'auto.function.mcp_server', + data: { + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.op': 'mcp.server', + 'sentry.source': 'route', + 'mcp.transport': 'WorkerTransport', + 'network.transport': 'unknown', + 'network.protocol.version': '2.0', + 'mcp.method.name': 'tools/call', + 'mcp.request.id': '1', + 'mcp.tool.name': 'my-tool', + 'mcp.request.argument.message': '"ʕっ•ᴥ•ʔっ"', + 'mcp.tool.extra': 'ƸӜƷ', + 'mcp.tool.input': '{"message":"ʕっ•ᴥ•ʔっ"}', + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', + 'mcp.tool.result.content': 'Tool my-tool: ʕっ•ᴥ•ʔっ', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/tsconfig.json new file mode 100644 index 000000000000..9a3b10f6cdc8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers"] + }, + "include": ["./**/*.ts", "../worker-configuration.d.ts"], + "exclude": [] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tsconfig.json new file mode 100644 index 000000000000..87d4bbd5fab8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2021", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["es2021"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/vitest.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/vitest.config.mts new file mode 100644 index 000000000000..931e5113e0c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: './wrangler.toml' }, + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/wrangler.toml new file mode 100644 index 000000000000..69c10ce7cf01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/wrangler.toml @@ -0,0 +1,98 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-mcp" +main = "src/index.ts" +compatibility_date = "2025-03-21" +compatibility_flags = ["nodejs_compat"] + +# [vars] +# E2E_TEST_DSN = "" + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 2de5147d3d5a..3332f71dab90 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,5 +1,6 @@ import type { ClientOptions, Options, ServerRuntimeClientOptions } from '@sentry/core'; -import { applySdkMetadata, ServerRuntimeClient } from '@sentry/core'; +import { applySdkMetadata, debug, ServerRuntimeClient } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; import type { makeFlushLock } from './flush'; import type { CloudflareTransportOptions } from './transport'; @@ -11,6 +12,9 @@ import type { CloudflareTransportOptions } from './transport'; */ export class CloudflareClient extends ServerRuntimeClient { private readonly _flushLock: ReturnType | void; + private _pendingSpans: Set = new Set(); + private _spanCompletionPromise: Promise | null = null; + private _resolveSpanCompletion: (() => void) | null = null; /** * Creates a new Cloudflare SDK instance. @@ -31,12 +35,40 @@ export class CloudflareClient extends ServerRuntimeClient { super(clientOptions); this._flushLock = flushLock; + + // Track span lifecycle to know when to flush + this.on('spanStart', span => { + const spanId = span.spanContext().spanId; + DEBUG_BUILD && debug.log('[CloudflareClient] Span started:', spanId); + this._pendingSpans.add(spanId); + + if (!this._spanCompletionPromise) { + this._spanCompletionPromise = new Promise(resolve => { + this._resolveSpanCompletion = resolve; + }); + } + }); + + this.on('spanEnd', span => { + const spanId = span.spanContext().spanId; + DEBUG_BUILD && debug.log('[CloudflareClient] Span ended:', spanId); + this._pendingSpans.delete(spanId); + + // If no more pending spans, resolve the completion promise + if (this._pendingSpans.size === 0 && this._resolveSpanCompletion) { + DEBUG_BUILD && debug.log('[CloudflareClient] All spans completed, resolving promise'); + this._resolveSpanCompletion(); + this._resetSpanCompletionPromise(); + } + }); } /** * Flushes pending operations and ensures all data is processed. * If a timeout is provided, the operation will be completed within the specified time limit. * + * It will wait for all pending spans to complete before flushing. + * * @param {number} [timeout] - Optional timeout in milliseconds to force the completion of the flush operation. * @return {Promise} A promise that resolves to a boolean indicating whether the flush operation was successful. */ @@ -44,8 +76,37 @@ export class CloudflareClient extends ServerRuntimeClient { if (this._flushLock) { await this._flushLock.finalize(); } + + if (this._pendingSpans.size > 0 && this._spanCompletionPromise) { + DEBUG_BUILD && + debug.log('[CloudflareClient] Waiting for', this._pendingSpans.size, 'pending spans to complete...'); + + const timeoutMs = timeout ?? 5000; + const spanCompletionRace = Promise.race([ + this._spanCompletionPromise, + new Promise(resolve => + setTimeout(() => { + DEBUG_BUILD && + debug.log('[CloudflareClient] Span completion timeout after', timeoutMs, 'ms, flushing anyway'); + resolve(undefined); + }, timeoutMs), + ), + ]); + + await spanCompletionRace; + } + return super.flush(timeout); } + + /** + * Resets the span completion promise and resolve function. + */ + private _resetSpanCompletionPromise(): void { + this._pendingSpans.clear(); + this._spanCompletionPromise = null; + this._resolveSpanCompletion = null; + } } interface BaseCloudflareOptions {