Skip to content

Commit 4ed32f0

Browse files
authored
Merge branch 'develop' into sig/nitro-dir-option
2 parents 166a98b + 9b30bd7 commit 4ed32f0

File tree

11 files changed

+236
-17
lines changed

11 files changed

+236
-17
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { NextPageContext } from 'next';
2+
import * as Sentry from '@sentry/nextjs';
3+
4+
interface ErrorProps {
5+
statusCode?: number;
6+
eventId?: string;
7+
lastEventId?: string;
8+
}
9+
10+
function ErrorPage({ statusCode, eventId, lastEventId }: ErrorProps) {
11+
return (
12+
<div>
13+
<h1>Error Page</h1>
14+
<p data-testid="status-code">Status Code: {statusCode}</p>
15+
<p data-testid="event-id">Event ID from return: {eventId || 'No event ID'}</p>
16+
<p data-testid="last-event-id">Event ID from lastEventId(): {lastEventId || 'No event ID'}</p>
17+
</div>
18+
);
19+
}
20+
21+
ErrorPage.getInitialProps = async (context: NextPageContext) => {
22+
const { res, err } = context;
23+
24+
const statusCode = res?.statusCode || err?.statusCode || 404;
25+
26+
// Capture the error using captureUnderscoreErrorException
27+
// This should return the already-captured event ID from the data fetcher
28+
const eventId = await Sentry.captureUnderscoreErrorException(context);
29+
30+
// Also get the last event ID from lastEventId()
31+
const lastEventId = Sentry.lastEventId();
32+
33+
return { statusCode, eventId, lastEventId };
34+
};
35+
36+
export default ErrorPage;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function TestErrorPage() {
2+
return <div>This page should never render</div>;
3+
}
4+
5+
export function getServerSideProps() {
6+
throw new Error('Test error to trigger _error.tsx page');
7+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
import { isDevMode } from './isDevMode';
4+
import { isNext13 } from './nextjsVersion';
5+
6+
test('lastEventId() should return the event ID after captureUnderscoreErrorException', async ({ page }) => {
7+
test.skip(isDevMode, 'should be skipped for non-dev mode');
8+
test.skip(isNext13, 'should be skipped for Next.js 13');
9+
10+
const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => {
11+
return errorEvent?.exception?.values?.[0]?.value === 'Test error to trigger _error.tsx page';
12+
});
13+
14+
await page.goto('/underscore-error/test-error-page');
15+
const errorEvent = await errorEventPromise;
16+
17+
// Since the error is already captured by withErrorInstrumentation in getServerSideProps,
18+
// the mechanism should be 'auto.function.nextjs.wrapped', not 'auto.function.nextjs.underscore_error'
19+
expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.function.nextjs.wrapped');
20+
// The function name might be e.g. 'getServerSideProps$1'
21+
expect(errorEvent.exception?.values?.[0]?.mechanism?.data?.function).toContain('getServerSideProps');
22+
expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false);
23+
24+
const eventIdFromReturn = await page.locator('[data-testid="event-id"]').textContent();
25+
const returnedEventId = eventIdFromReturn?.replace('Event ID from return: ', '');
26+
27+
const lastEventIdFromFunction = await page.locator('[data-testid="last-event-id"]').textContent();
28+
const lastEventId = lastEventIdFromFunction?.replace('Event ID from lastEventId(): ', '');
29+
30+
expect(returnedEventId).toBeDefined();
31+
expect(returnedEventId).not.toBe('No event ID');
32+
expect(lastEventId).toBeDefined();
33+
expect(lastEventId).not.toBe('No event ID');
34+
35+
expect(lastEventId).toBe(returnedEventId);
36+
expect(errorEvent.event_id).toBe(returnedEventId);
37+
expect(errorEvent.event_id).toBe(lastEventId);
38+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const packageJson = require('../package.json');
2+
const nextjsVersion = packageJson.dependencies.next;
3+
const nextjsMajor = Number(nextjsVersion.split('.')[0]);
4+
5+
export const isNext13 = !isNaN(nextjsMajor) && nextjsMajor === 13;

packages/browser/src/tracing/request.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -332,24 +332,28 @@ function xhrCallback(
332332

333333
const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(url);
334334

335-
// check first if the request has finished and is tracked by an existing span which should now end
336-
if (handlerData.endTimestamp && shouldCreateSpanResult) {
335+
// Handle XHR completion - clean up spans from the record
336+
if (handlerData.endTimestamp) {
337337
const spanId = xhr.__sentry_xhr_span_id__;
338338
if (!spanId) return;
339339

340340
const span = spans[spanId];
341-
if (span && sentryXhrData.status_code !== undefined) {
342-
setHttpStatus(span, sentryXhrData.status_code);
343-
span.end();
344341

345-
onRequestSpanEnd?.(span, {
346-
headers: createHeadersSafely(parseXhrResponseHeaders(xhr as XMLHttpRequest & SentryWrappedXMLHttpRequest)),
347-
error: handlerData.error,
348-
});
342+
if (span) {
343+
if (shouldCreateSpanResult && sentryXhrData.status_code !== undefined) {
344+
setHttpStatus(span, sentryXhrData.status_code);
345+
span.end();
346+
347+
onRequestSpanEnd?.(span, {
348+
headers: createHeadersSafely(parseXhrResponseHeaders(xhr as XMLHttpRequest & SentryWrappedXMLHttpRequest)),
349+
error: handlerData.error,
350+
});
351+
}
349352

350353
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
351354
delete spans[spanId];
352355
}
356+
353357
return undefined;
354358
}
355359

packages/core/src/fetch.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,23 @@ export function instrumentFetchRequest(
8181

8282
const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(url);
8383

84-
if (handlerData.endTimestamp && shouldCreateSpanResult) {
84+
if (handlerData.endTimestamp) {
8585
const spanId = handlerData.fetchData.__span;
8686
if (!spanId) return;
8787

8888
const span = spans[spanId];
89-
if (span) {
90-
endSpan(span, handlerData);
9189

92-
_callOnRequestSpanEnd(span, handlerData, spanOriginOrOptions);
90+
if (span) {
91+
// Only end the span and call hooks if we're actually recording
92+
if (shouldCreateSpanResult) {
93+
endSpan(span, handlerData);
94+
_callOnRequestSpanEnd(span, handlerData, spanOriginOrOptions);
95+
}
9396

9497
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
9598
delete spans[spanId];
9699
}
100+
97101
return undefined;
98102
}
99103

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export {
221221
addExceptionMechanism,
222222
addExceptionTypeValue,
223223
checkOrSetAlreadyCaught,
224+
isAlreadyCaptured,
224225
getEventDescription,
225226
parseSemver,
226227
uuid4,

packages/core/src/utils/misc.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,13 @@ export function checkOrSetAlreadyCaught(exception: unknown): boolean {
227227
return false;
228228
}
229229

230-
function isAlreadyCaptured(exception: unknown): boolean | void {
230+
/**
231+
* Checks whether we've already captured the given exception (note: not an identical exception - the very object).
232+
* It is considered already captured if it has the `__sentry_captured__` property set to `true`.
233+
*
234+
* @internal Only considered for internal usage
235+
*/
236+
export function isAlreadyCaptured(exception: unknown): boolean | void {
231237
try {
232238
return (exception as { __sentry_captured__?: boolean }).__sentry_captured__;
233239
} catch {} // eslint-disable-line no-empty

packages/core/test/lib/fetch.test.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { describe, expect, it, vi } from 'vitest';
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { HandlerDataFetch } from '../../src';
23
import { _addTracingHeadersToFetchRequest, instrumentFetchRequest } from '../../src/fetch';
34
import type { Span } from '../../src/types-hoist/span';
45

5-
const { DEFAULT_SENTRY_TRACE, DEFAULT_BAGGAGE } = vi.hoisted(() => ({
6+
const { DEFAULT_SENTRY_TRACE, DEFAULT_BAGGAGE, hasSpansEnabled } = vi.hoisted(() => ({
67
DEFAULT_SENTRY_TRACE: 'defaultTraceId-defaultSpanId-1',
78
DEFAULT_BAGGAGE: 'sentry-trace_id=defaultTraceId,sentry-sampled=true,sentry-sample_rate=0.5,sentry-sample_rand=0.232',
9+
hasSpansEnabled: vi.fn(),
810
}));
911

1012
const CUSTOM_SENTRY_TRACE = '123-abc-1';
@@ -23,7 +25,18 @@ vi.mock('../../src/utils/traceData', () => {
2325
};
2426
});
2527

28+
vi.mock('../../src/utils/hasSpansEnabled', () => {
29+
return {
30+
hasSpansEnabled,
31+
};
32+
});
33+
2634
describe('_addTracingHeadersToFetchRequest', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
hasSpansEnabled.mockReturnValue(false);
38+
});
39+
2740
describe('when request is a string', () => {
2841
describe('and no request headers are set', () => {
2942
it.each([
@@ -412,6 +425,53 @@ describe('_addTracingHeadersToFetchRequest', () => {
412425
});
413426

414427
describe('instrumentFetchRequest', () => {
428+
describe('span cleanup', () => {
429+
it.each([
430+
{ name: 'non-recording', hasTracingEnabled: false },
431+
{ name: 'recording', hasTracingEnabled: true },
432+
])('cleans up $name spans from the spans record when fetch completes', ({ hasTracingEnabled }) => {
433+
hasSpansEnabled.mockReturnValue(hasTracingEnabled);
434+
435+
const spans: Record<string, Span> = {};
436+
437+
const handlerData = {
438+
fetchData: { url: '/api/test', method: 'GET' },
439+
args: ['/api/test'] as unknown[],
440+
startTimestamp: Date.now(),
441+
};
442+
443+
instrumentFetchRequest(
444+
handlerData,
445+
() => true,
446+
() => false,
447+
spans,
448+
{ spanOrigin: 'auto.http.fetch' },
449+
);
450+
451+
// @ts-expect-error -- we know it exists
452+
const spanId = handlerData.fetchData.__span;
453+
454+
expect(spanId).toBeDefined();
455+
expect(spans[spanId]).toBeDefined();
456+
457+
const completedHandlerData: HandlerDataFetch = {
458+
...handlerData,
459+
endTimestamp: Date.now() + 100,
460+
response: { status: 200, headers: new Headers() } as Response,
461+
};
462+
463+
instrumentFetchRequest(
464+
completedHandlerData,
465+
() => true,
466+
() => false,
467+
spans,
468+
{ spanOrigin: 'auto.http.fetch' },
469+
);
470+
471+
expect(spans[spanId]).toBeUndefined();
472+
});
473+
});
474+
415475
describe('options object mutation', () => {
416476
it('does not mutate the original options object', () => {
417477
const originalOptions = { method: 'POST', body: JSON.stringify({ data: 'test' }) };

packages/nextjs/src/common/pages-router-instrumentation/_error.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { captureException, httpRequestToRequestData, withScope } from '@sentry/core';
1+
import {
2+
captureException,
3+
getIsolationScope,
4+
httpRequestToRequestData,
5+
isAlreadyCaptured,
6+
withScope,
7+
} from '@sentry/core';
28
import type { NextPageContext } from 'next';
39
import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd';
410

@@ -38,6 +44,13 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP
3844
return;
3945
}
4046

47+
// If the error was already captured (e.g., by wrapped functions in data fetchers),
48+
// return the existing event ID instead of capturing it again (needed for lastEventId() to work)
49+
if (err && isAlreadyCaptured(err)) {
50+
waitUntil(flushSafelyWithTimeout());
51+
return getIsolationScope().lastEventId();
52+
}
53+
4154
const eventId = withScope(scope => {
4255
if (req) {
4356
const normalizedRequest = httpRequestToRequestData(req);

0 commit comments

Comments
 (0)