Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ test('sends spans for MCP tool calls', async ({ baseURL }) => {
typeof mcpEvent === 'string' ||
!('contexts' in mcpEvent) ||
typeof requestEvent === 'string' ||
!('contexts' in requestEvent)
!('contexts' in requestEvent) ||
!('spans' in requestEvent)
) {
throw new Error("Events don't have contexts");
}
Expand All @@ -71,21 +72,55 @@ test('sends spans for MCP tool calls', async ({ baseURL }) => {
'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(requestEvent.spans).toEqual([
{
data: {
'sentry.origin': 'auto.http.cloudflare',
'sentry.op': 'http.server',
'sentry.source': 'url',
'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.accept': 'application/json, text/event-stream',
'http.request.header.accept_encoding': 'br, gzip',
'http.request.header.accept_language': '*',
'http.request.header.cf_connecting_ip': '::1',
'http.request.header.content_length': '120',
'http.request.header.content_type': 'application/json',
'http.request.header.host': 'localhost:38787',
'http.request.header.sec_fetch_mode': 'cors',
'http.request.header.user_agent': 'node',
'network.protocol.name': 'HTTP/1.1',
'mcp.server.extra': ' /|\ ^._.^ /|\ ',
'http.response.status_code': 200,
},
description: 'fetch',
op: 'http.server',
parent_span_id: expect.any(String),
span_id: expect.any(String),
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: expect.any(String),
origin: 'auto.http.cloudflare',
},
]);

expect(mcpEvent.contexts?.trace).toEqual({
trace_id: expect.any(String),
parent_span_id: requestEvent.contexts?.trace?.span_id,
parent_span_id: requestEvent.spans?.[0]?.span_id,
span_id: expect.any(String),
op: 'mcp.server',
origin: 'auto.function.mcp_server',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export default Sentry.withSentry(
},
}),
{
async fetch(request, env) {
async fetch(request, env, ctx) {
const url = new URL(request.url);
switch (url.pathname) {
case '/rpc/throwException':
Expand All @@ -96,6 +96,25 @@ export default Sentry.withSentry(
}
}
break;
case '/waitUntil':
console.log('waitUntil called');

const longRunningTask = async () => {
await new Promise(resolve => setTimeout(resolve, 2000));

console.log('ʕっ•ᴥ•ʔっ');
Sentry.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻'));

return Sentry.startSpan({ name: 'longRunningTask' }, async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
console.log(' /|\ ^._.^ /|\ ');
});
};

ctx.waitUntil(longRunningTask());

return new Response(null, { status: 200 });

case '/throwException':
throw new Error('To be recorded in Sentry.');
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForRequest } from '@sentry-internal/test-utils';
import { waitForError, waitForRequest, waitForTransaction } from '@sentry-internal/test-utils';
import { SDK_VERSION } from '@sentry/cloudflare';
import { WebSocket } from 'ws';

Expand Down Expand Up @@ -82,3 +82,114 @@ test('sends user-agent header with SDK name and version in envelope requests', a
'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`,
});
});

test.only('waitUntil', async ({ baseURL }) => {
const errorWaiter = waitForError(
'cloudflare-workers',
event => event.exception?.values?.[0]?.value === 'ʕノ•ᴥ•ʔノ ︵ ┻━┻',
);
const httpTransactionWaiter = waitForTransaction(
'cloudflare-workers',
transactionEvent => transactionEvent.contexts?.trace?.op === 'http.server',
);

const response = await fetch(`${baseURL}/waitUntil`);

expect(response.status).toBe(200);

const [errorEvent, transactionEvent] = await Promise.all([errorWaiter, httpTransactionWaiter]);

// ===== Error Event Assertions =====
expect(errorEvent.exception?.values?.[0]).toMatchObject({
type: 'Error',
value: 'ʕノ•ᴥ•ʔノ ︵ ┻━┻',
mechanism: {
type: 'generic',
handled: true,
},
});

// Error should have trace context linking it to the transaction
expect(errorEvent.contexts?.trace?.trace_id).toBeDefined();
expect(errorEvent.contexts?.trace?.span_id).toBeDefined();

// Error should have cloudflare-specific contexts
expect(errorEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' });
expect(errorEvent.contexts?.runtime).toEqual({ name: 'cloudflare' });

// Error should have request data
expect(errorEvent.request).toMatchObject({
method: 'GET',
url: expect.stringContaining('/waitUntil'),
});

// Error should have console breadcrumbs from before the error
expect(errorEvent.breadcrumbs).toEqual([
expect.objectContaining({ category: 'console', message: 'waitUntil called' }),
expect.objectContaining({ category: 'console', message: 'ʕっ•ᴥ•ʔっ' }),
]);

// ===== Transaction Event Assertions =====
expect(transactionEvent.transaction).toBe('GET /waitUntil');
expect(transactionEvent.type).toBe('transaction');
expect(transactionEvent.transaction_info?.source).toBe('url');

// Transaction trace context (root span - no status/response code, those are on the fetch child span)
expect(transactionEvent.contexts?.trace).toMatchObject({
op: 'http.server',
origin: 'auto.http.cloudflare',
data: expect.objectContaining({
'sentry.op': 'http.server',
'sentry.origin': 'auto.http.cloudflare',
'url.path': '/waitUntil',
}),
});

expect(transactionEvent.contexts?.trace).not.toEqual(
expect.objectContaining({
data: expect.objectContaining({
'http.request.method': 'GET',
'http.response.status_code': 200,
}),
}),
);

expect(transactionEvent.spans).toEqual([
expect.objectContaining({
status: 'ok',
description: 'fetch',
op: 'http.server',
origin: 'auto.http.cloudflare',
parent_span_id: transactionEvent.contexts?.trace?.span_id,
data: expect.objectContaining({
'http.request.method': 'GET',
'http.response.status_code': 200,
}),
}),
expect.objectContaining({
description: 'waitUntil',
op: 'cloudflare.wait_until',
origin: 'manual',
parent_span_id: transactionEvent.spans?.[0]?.span_id,
}),
expect.objectContaining({
description: 'longRunningTask',
origin: 'manual',
parent_span_id: transactionEvent.spans?.[0]?.span_id,
}),
]);

// Transaction should have all console breadcrumbs including the one after the span completes
expect(transactionEvent.breadcrumbs).toEqual([
expect.objectContaining({ category: 'console', message: 'waitUntil called' }),
expect.objectContaining({ category: 'console', message: 'ʕっ•ᴥ•ʔっ' }),
expect.objectContaining({ category: 'console', message: ' /|\ ^._.^ /|\ ' }),
]);

// ===== Cross-event Assertions =====
// Error and transaction should share the same trace_id
expect(transactionEvent.contexts?.trace?.trace_id).toBe(errorEvent.contexts?.trace?.trace_id);

// The error's span_id should match the fetch span's span_id (error captured during waitUntil execution)
expect(errorEvent.contexts?.trace?.span_id).toBe(transactionEvent.spans?.[0]?.span_id);
});
16 changes: 13 additions & 3 deletions packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ export class CloudflareClient extends ServerRuntimeClient {
});
}

/**
* Returns a promise that resolves when all waitUntil promises have completed.
* This allows the root span to stay open until all waitUntil work is done.
*
* @return {Promise<void>} A promise that resolves when all waitUntil promises are done.
*/
public async waitUntilDone(): Promise<void> {
if (this._flushLock) {
await this._flushLock.finalize();
}
}

/**
* Flushes pending operations and ensures all data is processed.
* If a timeout is provided, the operation will be completed within the specified time limit.
Expand All @@ -73,9 +85,7 @@ export class CloudflareClient extends ServerRuntimeClient {
* @return {Promise<boolean>} A promise that resolves to a boolean indicating whether the flush operation was successful.
*/
public async flush(timeout?: number): Promise<boolean> {
if (this._flushLock) {
await this._flushLock.finalize();
}
await this.waitUntilDone();

if (this._pendingSpans.size > 0 && this._spanCompletionPromise) {
DEBUG_BUILD &&
Expand Down
14 changes: 12 additions & 2 deletions packages/cloudflare/src/flush.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ExecutionContext } from '@cloudflare/workers-types';
import { startSpan } from '@sentry/core';

type FlushLock = {
readonly ready: Promise<void>;
Expand All @@ -22,9 +23,18 @@ export function makeFlushLock(context: ExecutionContext): FlushLock {
const originalWaitUntil = context.waitUntil.bind(context) as typeof context.waitUntil;
context.waitUntil = promise => {
pending++;

return originalWaitUntil(
promise.finally(() => {
if (--pending === 0) resolveAllDone();
// Wrap the promise in a new scope and transaction so spans created inside
// waitUntil callbacks are properly isolated from the HTTP request transaction
startSpan({ op: 'cloudflare.wait_until', name: 'waitUntil' }, async () => {
// By awaiting the promise inside the new scope, all of its continuations
// will execute in this isolated scope
await promise;
}).finally(() => {
if (--pending === 0) {
resolveAllDone();
}
}),
);
};
Expand Down
Loading
Loading