diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx index be718ae1b95c68..ebbc4d455076f0 100644 --- a/app/[[...path]]/page.tsx +++ b/app/[[...path]]/page.tsx @@ -30,6 +30,7 @@ import { getVersionsFromDoc, } from 'sentry-docs/mdx'; import {mdxComponents} from 'sentry-docs/mdxComponents'; +import {isExpectedMdxError} from 'sentry-docs/mdxErrors'; import {PageType} from 'sentry-docs/metrics'; import {setServerContext} from 'sentry-docs/serverContext'; import {PaginationNavNode} from 'sentry-docs/types/paginationNavNode'; @@ -162,14 +163,8 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) // This can happen when serverless function is invoked at runtime but docs files // aren't in the bundle, or MDX compilation is attempted at Vercel runtime. // Note: This error handling is duplicated for regular docs below - keep them in sync. - const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; - const isExpectedError = - errorCode === 'ENOENT' || - errorCode === 'MDX_RUNTIME_ERROR' || - (e instanceof Error && e.message.includes('Failed to find a valid source file')); - if (isExpectedError) { - // Log as warning for visibility without flooding errors - // Users are served static pages from CDN - this is an infrastructure edge case + if (isExpectedMdxError(e)) { + const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; Sentry.logger.warn('MDX file not found at runtime, returning 404', { path: params.path?.join('/'), errorCode, @@ -177,6 +172,13 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) }); return notFound(); } + // TODO(diagnostic): remove once Edge runtime error shape is confirmed + const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; + Sentry.logger.error('Unexpected error loading MDX, re-throwing as 500', { + path: params.path?.join('/'), + errorCode, + hasMessage: typeof (e as any)?.message === 'string', + }); throw e; } const {mdxSource, frontMatter} = doc; @@ -237,14 +239,8 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) // This can happen when serverless function is invoked at runtime but docs files // aren't in the bundle, or MDX compilation is attempted at Vercel runtime. // Note: This error handling is duplicated for developer docs above - keep them in sync. - const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; - const isExpectedError = - errorCode === 'ENOENT' || - errorCode === 'MDX_RUNTIME_ERROR' || - (e instanceof Error && e.message.includes('Failed to find a valid source file')); - if (isExpectedError) { - // Log as warning for visibility without flooding errors - // Users are served static pages from CDN - this is an infrastructure edge case + if (isExpectedMdxError(e)) { + const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; Sentry.logger.warn('MDX file not found at runtime, returning 404', { path: pageNode.path, errorCode, @@ -252,6 +248,13 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) }); return notFound(); } + // TODO(diagnostic): remove once Edge runtime error shape is confirmed + const errorCode = e && typeof e === 'object' && 'code' in e ? e.code : null; + Sentry.logger.error('Unexpected error loading MDX, re-throwing as 500', { + path: pageNode.path, + errorCode, + hasMessage: typeof (e as any)?.message === 'string', + }); throw e; } const {mdxSource, frontMatter} = doc; diff --git a/src/components/include.tsx b/src/components/include.tsx index fb4846c6ea4b9f..34eb66aa6435fd 100644 --- a/src/components/include.tsx +++ b/src/components/include.tsx @@ -2,6 +2,7 @@ import {useMemo} from 'react'; import {getMDXComponent} from 'sentry-docs/getMDXComponent'; import {getFileBySlugWithCache} from 'sentry-docs/mdx'; import {mdxComponents} from 'sentry-docs/mdxComponents'; +import {isExpectedMdxError} from 'sentry-docs/mdxErrors'; import {PlatformContent} from './platformContent'; @@ -21,9 +22,9 @@ export async function Include({name}: Props) { } try { doc = await getFileBySlugWithCache(`includes/${name}`); - } catch (e) { + } catch (e: unknown) { // Handle file not found (ENOENT) and runtime MDX compilation attempts gracefully - if (e.code === 'ENOENT' || e.code === 'MDX_RUNTIME_ERROR') { + if (isExpectedMdxError(e)) { return null; } throw e; diff --git a/src/mdx.spec.ts b/src/mdx.spec.ts index fffbb1b0b80834..79c929e4f177b1 100644 --- a/src/mdx.spec.ts +++ b/src/mdx.spec.ts @@ -1,6 +1,12 @@ -import {describe, expect, test} from 'vitest'; +import {afterEach, describe, expect, test, vi} from 'vitest'; -import {addVersionToFilePath, getVersionedIndexPath, getVersionsFromDoc} from './mdx'; +import { + addVersionToFilePath, + getFileBySlug, + getVersionedIndexPath, + getVersionsFromDoc, +} from './mdx'; +import {isExpectedMdxError} from './mdxErrors'; import {FrontMatter} from './types'; const mockFm: FrontMatter[] = [ @@ -117,4 +123,64 @@ describe('mdx', () => { ); }); }); + + describe('getFileBySlug error contracts', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('throws error with .code = ENOENT for non-existent slug', async () => { + let thrown: unknown; + try { + await getFileBySlug('nonexistent/path/that/does/not/exist'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(thrown).toHaveProperty('code', 'ENOENT'); + expect(thrown).toHaveProperty('message'); + expect((thrown as Error).message).toContain('Failed to find a valid source file'); + }); + + test('ENOENT error is recognized by isExpectedMdxError', async () => { + let thrown: unknown; + try { + await getFileBySlug('nonexistent/path/that/does/not/exist'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(isExpectedMdxError(thrown)).toBe(true); + }); + + test('throws error with .code = MDX_RUNTIME_ERROR in Vercel runtime', async () => { + vi.stubEnv('VERCEL', '1'); + vi.stubEnv('NODE_ENV', 'production'); + delete process.env.CI; + + let thrown: unknown; + try { + await getFileBySlug('any/slug'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(thrown).toHaveProperty('code', 'MDX_RUNTIME_ERROR'); + }); + + test('MDX_RUNTIME_ERROR is recognized by isExpectedMdxError', async () => { + vi.stubEnv('VERCEL', '1'); + vi.stubEnv('NODE_ENV', 'production'); + delete process.env.CI; + + let thrown: unknown; + try { + await getFileBySlug('any/slug'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(isExpectedMdxError(thrown)).toBe(true); + }); + }); }); diff --git a/src/mdxErrors.spec.ts b/src/mdxErrors.spec.ts new file mode 100644 index 00000000000000..e4ea45630d1447 --- /dev/null +++ b/src/mdxErrors.spec.ts @@ -0,0 +1,78 @@ +import {describe, expect, test} from 'vitest'; + +import {isExpectedMdxError} from './mdxErrors'; + +describe('isExpectedMdxError', () => { + describe('returns true for expected errors', () => { + test('Error with .code = ENOENT', () => { + const error = Object.assign(new Error('file not found'), {code: 'ENOENT'}); + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('Error with .code = MDX_RUNTIME_ERROR', () => { + const error = Object.assign(new Error('runtime error'), { + code: 'MDX_RUNTIME_ERROR', + }); + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('plain object with .code = ENOENT (Edge runtime simulation)', () => { + const error = {code: 'ENOENT', message: 'some error'}; + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('plain object with .code = MDX_RUNTIME_ERROR (Edge runtime simulation)', () => { + const error = {code: 'MDX_RUNTIME_ERROR', message: 'runtime error'}; + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('plain object with expected message but no .code (Edge runtime fallback)', () => { + const error = { + message: 'Failed to find a valid source file for slug "docs/changelog"', + }; + expect(isExpectedMdxError(error)).toBe(true); + }); + + test('Error with expected message but no .code', () => { + const error = new Error( + 'Failed to find a valid source file for slug "docs/changelog"' + ); + expect(isExpectedMdxError(error)).toBe(true); + }); + }); + + describe('returns false for unexpected errors', () => { + test('unrelated Error', () => { + expect(isExpectedMdxError(new Error('Connection refused'))).toBe(false); + }); + + test('Error with unrelated .code', () => { + const error = Object.assign(new Error('permission denied'), {code: 'EPERM'}); + expect(isExpectedMdxError(error)).toBe(false); + }); + + test('plain object with wrong message and no .code', () => { + expect(isExpectedMdxError({message: 'Something else went wrong'})).toBe(false); + }); + + test('string throw', () => { + expect(isExpectedMdxError('some string error')).toBe(false); + }); + + test('null', () => { + expect(isExpectedMdxError(null)).toBe(false); + }); + + test('undefined', () => { + expect(isExpectedMdxError(undefined)).toBe(false); + }); + + test('number', () => { + expect(isExpectedMdxError(42)).toBe(false); + }); + + test('empty object', () => { + expect(isExpectedMdxError({})).toBe(false); + }); + }); +}); diff --git a/src/mdxErrors.ts b/src/mdxErrors.ts new file mode 100644 index 00000000000000..cf051f006bf0f1 --- /dev/null +++ b/src/mdxErrors.ts @@ -0,0 +1,27 @@ +/** + * Determines whether a caught error represents an expected "page not found" + * condition that should result in a 404, rather than an unhandled 500. + * + * Uses duck-typing (.code property check) rather than instanceof, because + * instanceof Error returns false across Edge runtime VM context boundaries. + * Falls back to message-string matching for resilience in case .code is + * stripped during serialization. + */ +export function isExpectedMdxError(e: unknown): boolean { + const errorCode = + e && typeof e === 'object' && 'code' in e ? (e as {code: unknown}).code : null; + + if (errorCode === 'ENOENT' || errorCode === 'MDX_RUNTIME_ERROR') { + return true; + } + + // Fallback: duck-type the message for Edge runtime where .code may be stripped + if ( + typeof (e as any)?.message === 'string' && + (e as any).message.includes('Failed to find a valid source file') + ) { + return true; + } + + return false; +}