Skip to content
Merged
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
@@ -0,0 +1,7 @@
import { forbidden } from 'next/navigation';

export const dynamic = 'force-dynamic';

export default function ForbiddenPage() {
forbidden();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { forbidden } from 'next/navigation';

export const dynamic = 'force-dynamic';

export async function GET() {
forbidden();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { unauthorized } from 'next/navigation';

export const dynamic = 'force-dynamic';

export async function GET() {
unauthorized();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as Sentry from '@sentry/nextjs';
import { headers } from 'next/headers';
import { forbidden, unauthorized } from 'next/navigation';

export default function AuthInterruptsServerActionPage() {
async function forbiddenServerAction() {
'use server';
return await Sentry.withServerActionInstrumentation('forbiddenServerAction', { headers: await headers() }, () => {
forbidden();
});
}

async function unauthorizedServerAction() {
'use server';
return await Sentry.withServerActionInstrumentation(
'unauthorizedServerAction',
{ headers: await headers() },
() => {
unauthorized();
},
);
}

return (
<>
<form action={forbiddenServerAction}>
<button type="submit">Run Forbidden Action</button>
</form>
<form action={unauthorizedServerAction}>
<button type="submit">Run Unauthorized Action</button>
</form>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { unauthorized } from 'next/navigation';

export const dynamic = 'force-dynamic';

export default function UnauthorizedPage() {
unauthorized();
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ process.env.VERCEL = '1';

const nextConfig: NextConfig = {
experimental: {
authInterrupts: true,
sri: {
algorithm: 'sha256',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpansUntilSegment, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';

const cases = [
{ kind: 'forbidden', label: 'Forbidden', status: 403, statusMessage: 'permission_denied' },
{ kind: 'unauthorized', label: 'Unauthorized', status: 401, statusMessage: 'unauthenticated' },
] as const;

function waitForAuthInterruptError(status: number): Promise<unknown> {
return waitForError('nextjs-16', errorEvent => {
return JSON.stringify(errorEvent.exception ?? {}).includes(`NEXT_HTTP_ERROR_FALLBACK;${status}`);
});
}

// The error would be sent in the same flush as the spans, so a short grace period is enough.
async function expectNoError(errorPromise: Promise<unknown>): Promise<void> {
const result = await Promise.race([
errorPromise.then(() => 'error'),
new Promise(resolve => setTimeout(() => resolve('timeout'), 2000)),
]);
expect(result).toBe('timeout');
}

for (const { kind, label, status, statusMessage } of cases) {
test(`Does not capture ${kind}() in a server component and marks the render span`, async ({ page }) => {
const errorPromise = waitForAuthInterruptError(status);
const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', `GET /auth-interrupts/${kind}`);

const response = await page.goto(`/auth-interrupts/${kind}`);
expect(response?.status()).toBe(status);

const spans = await spansPromise;
const segmentSpan = spans.find(span => span.is_segment)!;
expect(segmentSpan.status).toBe('error');
expect(segmentSpan.attributes['http.response.status_code']?.value).toBe(status);

// Server components are only wrapped by our webpack loader, so only webpack builds set a status on the render span.
if (!segmentSpan.attributes['turbopack']) {
expect(spans).toContainEqual(
expect.objectContaining({
name: `render route (app) /auth-interrupts/${kind}`,
status: 'error',
attributes: expect.objectContaining({
'sentry.status.message': { value: statusMessage, type: 'string' },
}),
}),
);
}

await expectNoError(errorPromise);
});

test(`Does not capture ${kind}() in a server action and marks the action span`, async ({ page }) => {
const errorPromise = waitForAuthInterruptError(status);
const spanPromise = waitForStreamedSpan('nextjs-16', span => {
return span.name === `${kind}ServerAction` && span.is_segment;
});

await page.goto('/auth-interrupts/server-action');
await page.getByText(`Run ${label} Action`).click();

const span = await spanPromise;
expect(span.status).toBe('error');
expect(span.attributes['sentry.status.message']?.value).toBe(statusMessage);

await expectNoError(errorPromise);
});

test(`Does not capture ${kind}() in a route handler and sets the response status`, async ({ request }) => {
const errorPromise = waitForAuthInterruptError(status);
const spanPromise = waitForStreamedSpan('nextjs-16', span => {
return span.name === `GET /auth-interrupts/route-handler/${kind}` && span.is_segment;
});

const response = await request.get(`/auth-interrupts/route-handler/${kind}`);
expect(response.status()).toBe(status);

const span = await spanPromise;
expect(span.status).toBe('error');
expect(span.attributes['sentry.status.message']?.value).toBe(statusMessage);
expect(span.attributes['http.response.status_code']?.value).toBe(status);

await expectNoError(errorPromise);
});
}
14 changes: 14 additions & 0 deletions packages/nextjs/src/common/nextNavigationErrorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ export function isNotFoundNavigationError(subject: unknown): boolean {
return hasDigest(subject, digest => ['NEXT_NOT_FOUND', 'NEXT_HTTP_ERROR_FALLBACK;404'].includes(digest));
}

/**
* Returns the HTTP status of a Next.js `forbidden()` (403) or `unauthorized()` (401) error, if input is one.
* https://nextjs.org/docs/app/api-reference/functions/forbidden
*/
export function getAuthInterruptStatusCode(subject: unknown): 401 | 403 | undefined {
if (hasDigest(subject, digest => digest === 'NEXT_HTTP_ERROR_FALLBACK;403')) {
return 403;
}
if (hasDigest(subject, digest => digest === 'NEXT_HTTP_ERROR_FALLBACK;401')) {
return 401;
}
return undefined;
}

/**
* Determines whether input is a Next.js redirect error.
* https://beta.nextjs.org/docs/api-reference/redirect#redirect
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getActiveSpan,
getClient,
getIsolationScope,
getSpanStatusFromHttpCode,
handleCallbackErrors,
hasSpanStreamingEnabled,
SPAN_STATUS_ERROR,
Expand All @@ -16,6 +17,7 @@ import {
import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd';
import { DEBUG_BUILD } from './debug-build';
import {
getAuthInterruptStatusCode,
isNotFoundNavigationError,
isPrerenderControlFlowError,
isRedirectNavigationError,
Expand Down Expand Up @@ -144,9 +146,12 @@ async function withServerActionInstrumentationImplementation<A extends (...args:
async span => {
// oxlint-disable-next-line typescript/await-thenable -- callback may be async at runtime
const result = await handleCallbackErrors(callback, error => {
const authInterruptStatusCode = getAuthInterruptStatusCode(error);
if (isNotFoundNavigationError(error)) {
// We don't want to report "not-found"s
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
} else if (authInterruptStatusCode) {
span.setStatus(getSpanStatusFromHttpCode(authInterruptStatusCode));
} else if (isRedirectNavigationError(error)) {
// Redirects are normal Next.js control flow, not errors. Mark the span as OK and end it
// early so the surrounding `startSpan` error handler doesn't override the status to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import {
captureException,
getActiveSpan,
getIsolationScope,
getSpanStatusFromHttpCode,
handleCallbackErrors,
SPAN_STATUS_ERROR,
SPAN_STATUS_OK,
winterCGHeadersToDict,
} from '@sentry/core';
import type { GenerationFunctionContext } from '../common/types';
import {
getAuthInterruptStatusCode,
isNotFoundNavigationError,
isPrerenderControlFlowError,
isRedirectNavigationError,
Expand Down Expand Up @@ -59,6 +61,12 @@ export function wrapGenerationFunctionWithSentry<F extends (...args: any[]) => a
return;
}

const authInterruptStatusCode = getAuthInterruptStatusCode(error);
if (authInterruptStatusCode) {
span?.setStatus(getSpanStatusFromHttpCode(authInterruptStatusCode));
return;
}

if (isRedirectNavigationError(error)) {
// We don't want to report redirects
span?.setStatus({ code: SPAN_STATUS_OK });
Expand Down
9 changes: 9 additions & 0 deletions packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
withScope,
} from '@sentry/core';
import {
getAuthInterruptStatusCode,
isNotFoundNavigationError,
isPrerenderControlFlowError,
isRedirectNavigationError,
Expand Down Expand Up @@ -83,6 +84,7 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
const response: Response = await handleCallbackErrors(
() => originalFunction.apply(thisArg, args),
error => {
const authInterruptStatusCode = getAuthInterruptStatusCode(error);
// Next.js throws errors when calling `redirect()`. We don't wanna report these.
if (isRedirectNavigationError(error)) {
// Don't do anything
Expand All @@ -93,6 +95,13 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
if (rootSpan) {
setHttpStatus(rootSpan, 404);
}
} else if (authInterruptStatusCode) {
if (activeSpan) {
setHttpStatus(activeSpan, authInterruptStatusCode);
}
if (rootSpan) {
setHttpStatus(rootSpan, authInterruptStatusCode);
}
} else if (isPrerenderControlFlowError(error)) {
// Next.js aborts prerenders by rejecting the promises it handed out. React discards those
// rejections, so they are expected, do not affect the response, and must not be reported.
Expand Down
8 changes: 8 additions & 0 deletions packages/nextjs/src/common/wrapServerComponentWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import {
captureException,
getActiveSpan,
getIsolationScope,
getSpanStatusFromHttpCode,
handleCallbackErrors,
SPAN_STATUS_ERROR,
SPAN_STATUS_OK,
winterCGHeadersToDict,
} from '@sentry/core';
import {
getAuthInterruptStatusCode,
isNotFoundNavigationError,
isPrerenderControlFlowError,
isRedirectNavigationError,
Expand Down Expand Up @@ -54,6 +56,12 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
return;
}

const authInterruptStatusCode = getAuthInterruptStatusCode(error);
if (authInterruptStatusCode) {
span?.setStatus(getSpanStatusFromHttpCode(authInterruptStatusCode));
return;
}

if (isRedirectNavigationError(error)) {
// We don't want to report redirects
span?.setStatus({ code: SPAN_STATUS_OK });
Expand Down
17 changes: 17 additions & 0 deletions packages/nextjs/test/common/nextNavigationErrorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
getAuthInterruptStatusCode,
isNotFoundNavigationError,
isPrerenderControlFlowError,
isRedirectNavigationError,
Expand All @@ -21,6 +22,22 @@ describe('isNotFoundNavigationError', () => {
});
});

describe('getAuthInterruptStatusCode', () => {
it.each([
['NEXT_HTTP_ERROR_FALLBACK;403', 403],
['NEXT_HTTP_ERROR_FALLBACK;401', 401],
])('returns the status for the %s digest', (digest, status) => {
expect(getAuthInterruptStatusCode(errorWithDigest(digest))).toBe(status);
expect(getAuthInterruptStatusCode(new Error('wrapped', { cause: errorWithDigest(digest) }))).toBe(status);
});

it('returns undefined for other errors', () => {
expect(getAuthInterruptStatusCode(errorWithDigest('NEXT_HTTP_ERROR_FALLBACK;404'))).toBeUndefined();
expect(getAuthInterruptStatusCode(new Error('boom'))).toBeUndefined();
expect(getAuthInterruptStatusCode({ digest: 'NEXT_HTTP_ERROR_FALLBACK;403' })).toBeUndefined();
});
});

describe('isRedirectNavigationError', () => {
it('detects a redirect digest', () => {
expect(isRedirectNavigationError(errorWithDigest('NEXT_REDIRECT;/some-path'))).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ describe('wrapServerComponentWithSentry', () => {
it.each([
['not-found', 'NEXT_NOT_FOUND'],
['redirect', 'NEXT_REDIRECT;/somewhere'],
['forbidden', 'NEXT_HTTP_ERROR_FALLBACK;403'],
['unauthorized', 'NEXT_HTTP_ERROR_FALLBACK;401'],
['hanging prerender promise', 'HANGING_PROMISE_REJECTION'],
['prerender interruption', 'NEXT_PRERENDER_INTERRUPTED'],
['dynamic server usage', 'DYNAMIC_SERVER_USAGE'],
Expand All @@ -53,6 +55,18 @@ describe('wrapServerComponentWithSentry', () => {
expect(captureException).not.toHaveBeenCalled();
});

it.each([
['NEXT_HTTP_ERROR_FALLBACK;403', 'permission_denied'],
['NEXT_HTTP_ERROR_FALLBACK;401', 'unauthenticated'],
])('marks the active span for the %s auth interrupt', async (digest, message) => {
vi.spyOn(SentryCore, 'captureException').mockImplementation(() => '');
const { setStatus } = mockActiveSpan({} as Span);

await runWrapped(Object.assign(new Error('control flow'), { digest }));

expect(setStatus).toHaveBeenCalledWith({ code: SentryCore.SPAN_STATUS_ERROR, message });
});

it.each(['NEXT_NOT_FOUND', 'NEXT_REDIRECT;/somewhere', 'HANGING_PROMISE_REJECTION'])(
'does not capture the %s control flow error when there is no active span',
async digest => {
Expand Down
Loading