Skip to content

feat(nuxt): Add Cloudflare Nitro plugin #15597

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes from 10 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
60 changes: 41 additions & 19 deletions packages/cloudflare/src/request.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types';
import type { Span } from '@sentry/core';
import {
captureException,
continueTrace,
flush,
getCurrentScope,
getHttpSpanDetailsFromUrlObject,
parseStringToURLObject,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
Expand All @@ -15,7 +17,18 @@ import { addCloudResourceContext, addCultureContext, addRequest } from './scope-
import { init } from './sdk';

interface RequestHandlerWrapperOptions {
options: CloudflareOptions;
options: CloudflareOptions & {
/**
* Enable or disable the automatic continuation of traces from the propagation context.
*
* When enabled, the SDK will continue a trace from the propagation context if it is present.
*
* When disabled, the SDK will fall back to the default case of continuing a trace from the request headers if they are present.
*
* @default false
*/
continueTraceFromPropagationContext?: boolean;
Copy link
Member Author

@s1gr1d s1gr1d Jun 26, 2025

Choose a reason for hiding this comment

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

@AbhiPrasad Is this a good place for adding the option? As it's only needed in the requestHandler, I added it only here and not in the general Cloudflare SDK options.

Actually, I don't need this option because it would always take same trace ID from the Cloudflare environment which leads to multiple pageload spans within a trace as they all have the same trace ID in the meta tags.

};
request: Request<unknown, IncomingRequestCfProperties<unknown>>;
context: ExecutionContext;
}
Expand Down Expand Up @@ -69,31 +82,40 @@ export function wrapRequestHandler(
}
}

// Check if we already have active trace data - if so, don't wrap with continueTrace
// This allows us to continue an existing trace from the parent context (e.g. in Nuxt)
const existingPropagationContext = getCurrentScope().getPropagationContext();
if (options.continueTraceFromPropagationContext && existingPropagationContext?.traceId) {
return startSpan({ name, attributes }, cloudflareStartSpanCallback(handler, context));
}

// No active trace, create one from headers
return continueTrace(
{ sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') },
() => {
// Note: This span will not have a duration unless I/O happens in the handler. This is
// because of how the cloudflare workers runtime works.
// See: https://developers.cloudflare.com/workers/runtime-apis/performance/
return startSpan(
{
name,
attributes,
},
async span => {
try {
const res = await handler();
setHttpStatus(span, res.status);
return res;
} catch (e) {
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
throw e;
} finally {
context?.waitUntil(flush(2000));
}
},
);
return startSpan({ name, attributes }, cloudflareStartSpanCallback(handler, context));
},
);
});
}

function cloudflareStartSpanCallback(
handler: (...args: unknown[]) => Response | Promise<Response>,
context?: ExecutionContext,
) {
return async (span: Span) => {
try {
const res = await handler();
setHttpStatus(span, res.status);
return res;
} catch (e) {
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
throw e;
} finally {
context?.waitUntil(flush(2000));
}
};
}
72 changes: 72 additions & 0 deletions packages/cloudflare/test/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,78 @@ describe('withSentry', () => {
});
});

test('uses existing propagation context when continueTraceFromPropagationContext is enabled', async () => {
const mockGetCurrentScope = vi.spyOn(SentryCore, 'getCurrentScope').mockReturnValue({
// return an existing propagation context
getPropagationContext: () => ({
traceId: '12312012123120121231201212312012',
spanId: '1121201211212012',
}),
} as any);

const mockStartSpan = vi.spyOn(SentryCore, 'startSpan');
const mockContinueTrace = vi.spyOn(SentryCore, 'continueTrace');

const mockRequest = new Request('https://example.com') as any;
// Headers should be ignored when continuing from propagation context
mockRequest.headers.set('sentry-trace', '99999999999999999999999999999999-9999999999999999-1');

await wrapRequestHandler(
{
options: {
...MOCK_OPTIONS,
continueTraceFromPropagationContext: true,
},
request: mockRequest,
context: createMockExecutionContext(),
},
() => new Response('test'),
);

// Should use startSpan directly instead of continueTrace
expect(mockStartSpan).toHaveBeenCalledTimes(1);
expect(mockContinueTrace).not.toHaveBeenCalled();

mockGetCurrentScope.mockRestore();
mockStartSpan.mockRestore();
mockContinueTrace.mockRestore();
});

test('falls back to header-based trace when continueTraceFromPropagationContext is disabled or no context exists', async () => {
const mockGetCurrentScope = vi.spyOn(SentryCore, 'getCurrentScope').mockReturnValue({
// return an existing propagation context
getPropagationContext: () => ({
traceId: '12312012123120121231201212312012',
spanId: '1121201211212012',
}),
} as any);

const mockContinueTrace = vi.spyOn(SentryCore, 'continueTrace').mockImplementation((_, callback) => {
return callback();
});

const mockRequest = new Request('https://example.com') as any;
mockRequest.headers.set('sentry-trace', '99999999999999999999999999999999-9999999999999999-1');

await wrapRequestHandler(
{
options: {
...MOCK_OPTIONS,
continueTraceFromPropagationContext: false, // Explicitly disabled
},
request: mockRequest,
context: createMockExecutionContext(),
},
() => new Response('test'),
);

// Should use continueTrace even with existing propagation context when option is disabled
expect(mockContinueTrace).toHaveBeenCalledTimes(1);

mockGetCurrentScope.mockRestore();
mockContinueTrace.mockRestore();
});

test('creates a span that wraps request handler', async () => {
const mockRequest = new Request('https://example.com') as any;
mockRequest.cf = {
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/utils/meta.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SerializedTraceData } from '../types-hoist/tracing';
import { getTraceData } from './traceData';

/**
Expand All @@ -21,8 +22,9 @@ import { getTraceData } from './traceData';
* ```
*
*/
export function getTraceMetaTags(): string {
return Object.entries(getTraceData())
// todo add test for trace data argument
export function getTraceMetaTags(traceData?: SerializedTraceData): string {
return Object.entries(traceData || getTraceData())
.map(([key, value]) => `<meta name="${key}" content="${value}"/>`)
.join('\n');
}
16 changes: 16 additions & 0 deletions packages/core/test/lib/utils/meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,20 @@ describe('getTraceMetaTags', () => {

expect(getTraceMetaTags()).toBe('');
});

it('uses provided traceData instead of calling getTraceData()', () => {
const getTraceDataSpy = vi.spyOn(TraceDataModule, 'getTraceData');

const customTraceData = {
'sentry-trace': 'ab12345678901234567890123456789012-1234567890abcdef-1',
baggage:
'sentry-environment=test,sentry-public_key=public12345,sentry-trace_id=ab12345678901234567890123456789012,sentry-sample_rate=0.5',
};

expect(getTraceMetaTags(customTraceData))
.toBe(`<meta name="sentry-trace" content="ab12345678901234567890123456789012-1234567890abcdef-1"/>
<meta name="baggage" content="sentry-environment=test,sentry-public_key=public12345,sentry-trace_id=ab12345678901234567890123456789012,sentry-sample_rate=0.5"/>`);

expect(getTraceDataSpy).not.toHaveBeenCalled();
});
});
5 changes: 5 additions & 0 deletions packages/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"types": "./build/module/types.d.ts",
"import": "./build/module/module.mjs",
"require": "./build/module/module.cjs"
},
"./module/plugins": {
"types": "./build/module/runtime/plugins/index.d.ts",
"import": "./build/module/runtime/plugins/index.js"
}
},
"publishConfig": {
Expand All @@ -45,6 +49,7 @@
"@nuxt/kit": "^3.13.2",
"@sentry/browser": "9.30.0",
"@sentry/core": "9.30.0",
"@sentry/cloudflare": "9.30.0",
"@sentry/node": "9.30.0",
"@sentry/rollup-plugin": "^3.5.0",
"@sentry/vite-plugin": "^3.5.0",
Expand Down
47 changes: 47 additions & 0 deletions packages/nuxt/src/runtime/hooks/captureErrorHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { captureException, getClient, getCurrentScope } from '@sentry/core';
// eslint-disable-next-line import/no-extraneous-dependencies
import { H3Error } from 'h3';
import type { CapturedErrorContext } from 'nitropack';
import { extractErrorContext, flushIfServerless } from '../utils';

/**
* Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry.
*/
export async function sentryCaptureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise<void> {
const sentryClient = getClient();
const sentryClientOptions = sentryClient?.getOptions();

if (
sentryClientOptions &&
'enableNitroErrorHandler' in sentryClientOptions &&
sentryClientOptions.enableNitroErrorHandler === false
) {
return;
}

// Do not handle 404 and 422
if (error instanceof H3Error) {
// Do not report if status code is 3xx or 4xx
if (error.statusCode >= 300 && error.statusCode < 500) {
return;
}
}

const { method, path } = {
method: errorContext.event?._method ? errorContext.event._method : '',
path: errorContext.event?._path ? errorContext.event._path : null,
};

if (path) {
getCurrentScope().setTransactionName(`${method} ${path}`);
}

const structuredContext = extractErrorContext(errorContext);

captureException(error, {
captureContext: { contexts: { nuxt: structuredContext } },
mechanism: { handled: false },
});

await flushIfServerless();
}
2 changes: 2 additions & 0 deletions packages/nuxt/src/runtime/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// fixme: Can this be exported like this?
export { sentryCloudflareNitroPlugin } from './sentry-cloudflare.server';
Loading