diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 4786fe1e4aa9..6cfccfbd2714 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -60,7 +60,13 @@ function findDefaultSdkInitFile(type: 'server' | 'client'): string | undefined { const cwd = process.cwd(); const filePath = possibleFileExtensions - .map(e => path.resolve(path.join(cwd, `sentry.${type}.config.${e}`))) + .map(e => + path.resolve( + type === 'server' + ? path.join(cwd, 'public', `instrument.${type}.${e}`) + : path.join(cwd, `sentry.${type}.config.${e}`), + ), + ) .find(filename => fs.existsSync(filename)); return filePath ? path.basename(filePath) : undefined; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index f0a815375ec8..476037ac980b 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,7 +1,8 @@ import { captureException } from '@sentry/node'; import { H3Error } from 'h3'; import { defineNitroPlugin } from 'nitropack/runtime'; -import { extractErrorContext } from '../utils'; +import type { NuxtRenderHTMLContext } from 'nuxt/app'; +import { addSentryTracingMetaTags, extractErrorContext } from '../utils'; export default defineNitroPlugin(nitroApp => { nitroApp.hooks.hook('error', (error, errorContext) => { @@ -20,4 +21,9 @@ export default defineNitroPlugin(nitroApp => { mechanism: { handled: false }, }); }); + + // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context + nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext) => { + addSentryTracingMetaTags(html.head); + }); }); diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 07294806a546..b7c038ddd505 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,6 +1,10 @@ +import { getActiveSpan, getRootSpan, spanToTraceHeader } from '@sentry/core'; +import { getDynamicSamplingContextFromSpan } from '@sentry/opentelemetry'; import type { Context } from '@sentry/types'; import { dropUndefinedKeys } from '@sentry/utils'; +import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; import type { CapturedErrorContext } from 'nitropack'; +import type { NuxtRenderHTMLContext } from 'nuxt/app'; /** * Extracts the relevant context information from the error context (H3Event in Nitro Error) @@ -26,3 +30,23 @@ export function extractErrorContext(errorContext: CapturedErrorContext): Context return dropUndefinedKeys(structuredContext); } + +/** + * Adds Sentry tracing tags to the returned html page. + * + * Exported only for testing + */ +export function addSentryTracingMetaTags(head: NuxtRenderHTMLContext['head']): void { + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + + if (rootSpan) { + const traceParentData = spanToTraceHeader(rootSpan); + const dynamicSamplingContext = dynamicSamplingContextToSentryBaggageHeader( + getDynamicSamplingContextFromSpan(rootSpan), + ); + + head.push(``); + head.push(``); + } +} diff --git a/packages/nuxt/test/server/runtime/plugin.test.ts b/packages/nuxt/test/server/runtime/plugin.test.ts new file mode 100644 index 000000000000..518b20026cbd --- /dev/null +++ b/packages/nuxt/test/server/runtime/plugin.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { addSentryTracingMetaTags } from '../../../src/runtime/utils'; + +const mockReturns = vi.hoisted(() => { + return { + traceHeader: 'trace-header', + baggageHeader: 'baggage-header', + }; +}); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + + return { + ...actual, + getActiveSpan: vi.fn().mockReturnValue({ spanId: '123' }), + getRootSpan: vi.fn().mockReturnValue({ spanId: 'root123' }), + spanToTraceHeader: vi.fn(() => mockReturns.traceHeader), + }; +}); + +vi.mock('@sentry/opentelemetry', async () => { + const actual = await vi.importActual('@sentry/opentelemetry'); + + return { + ...actual, + getDynamicSamplingContextFromSpan: vi.fn().mockReturnValue('contextValue'), + }; +}); + +vi.mock('@sentry/utils', async () => { + const actual = await vi.importActual('@sentry/utils'); + + return { + ...actual, + dynamicSamplingContextToSentryBaggageHeader: vi.fn().mockReturnValue(mockReturns.baggageHeader), + }; +}); + +describe('addSentryTracingMetaTags', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should add meta tags when there is an active root span', () => { + const head: string[] = []; + addSentryTracingMetaTags(head); + + expect(head).toContain(``); + expect(head).toContain(``); + }); + + it('should not add meta tags when there is no active root span', () => { + vi.doMock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + + return { + ...actual, + getActiveSpan: vi.fn().mockReturnValue(undefined), + }; + }); + + const head: string[] = []; + addSentryTracingMetaTags(head); + + expect(head).toHaveLength(0); + }); +});