From 47e1b7ede1109e81263b316a777f485367b863a6 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:43:00 +0200 Subject: [PATCH] feat(nuxt): Add server error hook (#12796) Reports errors thrown in nitro. Tests will be added when adding the E2E test application. closes https://github.com/getsentry/sentry-javascript/issues/12795 --- packages/nuxt/src/module.ts | 4 +- .../nuxt/src/runtime/plugins/sentry.server.ts | 23 ++++++ packages/nuxt/src/runtime/utils.ts | 28 +++++++ .../nuxt/test/client/runtime/utils.test.ts | 79 +++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 packages/nuxt/src/runtime/plugins/sentry.server.ts create mode 100644 packages/nuxt/src/runtime/utils.ts create mode 100644 packages/nuxt/test/client/runtime/utils.test.ts diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index b4b5a2d9d7ea..9c14abd6feea 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { addPlugin, addPluginTemplate, createResolver, defineNuxtModule } from '@nuxt/kit'; +import { addPlugin, addPluginTemplate, addServerPlugin, createResolver, defineNuxtModule } from '@nuxt/kit'; import type { SentryNuxtOptions } from './common/types'; export type ModuleOptions = SentryNuxtOptions; @@ -44,6 +44,8 @@ export default defineNuxtModule({ `import "${buildDirResolver.resolve(`/${serverConfigFile}`)}"\n` + 'export default defineNuxtPlugin(() => {})', }); + + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); } }, }); diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts new file mode 100644 index 000000000000..f0a815375ec8 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -0,0 +1,23 @@ +import { captureException } from '@sentry/node'; +import { H3Error } from 'h3'; +import { defineNitroPlugin } from 'nitropack/runtime'; +import { extractErrorContext } from '../utils'; + +export default defineNitroPlugin(nitroApp => { + nitroApp.hooks.hook('error', (error, errorContext) => { + // 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 structuredContext = extractErrorContext(errorContext); + + captureException(error, { + captureContext: { contexts: { nuxt: structuredContext } }, + mechanism: { handled: false }, + }); + }); +}); diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts new file mode 100644 index 000000000000..07294806a546 --- /dev/null +++ b/packages/nuxt/src/runtime/utils.ts @@ -0,0 +1,28 @@ +import type { Context } from '@sentry/types'; +import { dropUndefinedKeys } from '@sentry/utils'; +import type { CapturedErrorContext } from 'nitropack'; + +/** + * Extracts the relevant context information from the error context (H3Event in Nitro Error) + * and created a structured context object. + */ +export function extractErrorContext(errorContext: CapturedErrorContext): Context { + const structuredContext: Context = { + method: undefined, + path: undefined, + tags: undefined, + }; + + if (errorContext) { + if (errorContext.event) { + structuredContext.method = errorContext.event._method || undefined; + structuredContext.path = errorContext.event._path || undefined; + } + + if (Array.isArray(errorContext.tags)) { + structuredContext.tags = errorContext.tags || undefined; + } + } + + return dropUndefinedKeys(structuredContext); +} diff --git a/packages/nuxt/test/client/runtime/utils.test.ts b/packages/nuxt/test/client/runtime/utils.test.ts new file mode 100644 index 000000000000..b0b039d52e54 --- /dev/null +++ b/packages/nuxt/test/client/runtime/utils.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { extractErrorContext } from '../../../src/runtime/utils'; + +describe('extractErrorContext', () => { + it('returns empty object for undefined or empty context', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(extractErrorContext(undefined)).toEqual({}); + expect(extractErrorContext({})).toEqual({}); + }); + + it('extracts properties from errorContext and drops them if missing', () => { + const context = { + event: { + _method: 'GET', + _path: '/test', + }, + tags: ['tag1', 'tag2'], + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(extractErrorContext(context)).toEqual({ + method: 'GET', + path: '/test', + tags: ['tag1', 'tag2'], + }); + + const partialContext = { + event: { + _path: '/test', + }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(extractErrorContext(partialContext)).toEqual({ path: '/test' }); + }); + + it('handles errorContext.tags correctly, including when absent or of unexpected type', () => { + const contextWithTags = { + tags: ['tag1', 'tag2'], + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(extractErrorContext(contextWithTags)).toEqual({ + tags: ['tag1', 'tag2'], + }); + + const contextWithoutTags = { + event: {}, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(extractErrorContext(contextWithoutTags)).toEqual({}); + + const contextWithInvalidTags = { + event: {}, + tags: 'not-an-array', + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(extractErrorContext(contextWithInvalidTags)).toEqual({}); + }); + + it('gracefully handles unexpected context structure without throwing errors', () => { + const weirdContext1 = { + unexpected: 'value', + }; + const weirdContext2 = ['value']; + const weirdContext3 = 123; + + expect(() => extractErrorContext(weirdContext1)).not.toThrow(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(() => extractErrorContext(weirdContext2)).not.toThrow(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(() => extractErrorContext(weirdContext3)).not.toThrow(); + }); +});