diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts index 2151f91d8479..c273df34e0a6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts @@ -9,11 +9,8 @@ test.describe('Cache Instrumentation', () => { const spans = await collectStreamedSpans('nuxt-4', spans => spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/cache-test'), ); - const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/cache-test'); - return spans.filter( - span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.cache.nuxt', - ); + return spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.cache.nuxt'); } test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts index 1ae00218f323..76648c4430c4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts @@ -45,6 +45,49 @@ test('sends a navigation root span with a parameterized URL', async ({ page }) = }); }); +test('sends an application render span and a root component span on pageload', async ({ page }) => { + const spansPromise = collectStreamedSpans('nuxt-4', spans => + spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'), + ); + + await page.goto(`/client-error`); + + const spans = await spansPromise; + const uiSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ui.vue'); + + const applicationRenderSpans = uiSpans.filter(span => span.name === 'Application Render'); + expect(applicationRenderSpans).toHaveLength(1); + expect(applicationRenderSpans[0]).toMatchObject({ + name: 'Application Render', + is_segment: false, + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.render' }, + 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + }), + }); + + const rootComponentSpans = uiSpans.filter(span => span.name === 'Vue '); + expect(rootComponentSpans).toHaveLength(1); + expect(rootComponentSpans[0]).toMatchObject({ + name: 'Vue ', + is_segment: false, + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.mount' }, + 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + }), + }); +}); + test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { const spansPromise = collectStreamedSpans('nuxt-4', spans => spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts index 74ee67cee564..70419d18389e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts @@ -139,9 +139,9 @@ test.describe('distributed tracing', () => { }), }); - // All 3 root spans and the http.client span should share the same trace_id + // `collectStreamedSpans` already guarantees the pageload and http.client spans share a trace, + // so only the independently awaited server spans need the check. expect(pageloadSpan?.trace_id).toBeDefined(); - expect(pageloadSpan?.trace_id).toBe(httpClientSpan?.trace_id); expect(pageloadSpan?.trace_id).toBe(ssrSpan.trace_id); expect(pageloadSpan?.trace_id).toBe(serverReqSpan.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts index 9c7fa6c6c9b6..bdef334cfa88 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -3,10 +3,6 @@ export default defineNuxtConfig({ compatibilityDate: '2025-07-15', imports: { autoImport: false }, - // FIXME: Remove once component tracking no longer relies on Vue's Options API. - // Nuxt 5 disables it by default: https://github.com/nuxt/nuxt/pull/35791 - vue: { optionsApi: true }, - routeRules: { '/rendering-modes/client-side-only-page': { ssr: false }, '/rendering-modes/isr-cached-page': { isr: true }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts index 186d52cd1a03..0d8aa3d701e3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts @@ -1,13 +1,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; -async function collectRequestSpans() { - const spans = await collectStreamedSpans('nuxt-5', spans => +function collectRequestSpans() { + return collectStreamedSpans('nuxt-5', spans => spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test'), ); - const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test'); - - return spans.filter(span => span.trace_id === rootSpan?.trace_id); } test.describe('Server Middleware Instrumentation', () => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts index 8c28561ec20c..cb5bbab420e8 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts @@ -45,8 +45,12 @@ test('sends a navigation root span with a parameterized URL', async ({ page }) = }); }); -// fixme: note that this test only works because we explictly enabled Vue’s Options API in the nuxt config test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { + // Nuxt 5 disables the Options API by default (nuxt/nuxt#35791), which turns `app.mixin()` into a + // no-op, and that mixin is where the SDK creates every UI span. Flips to passing once component + // tracking works without it. + test.fail(true, 'Vue tracing is registered through app.mixin(), which needs the Options API'); + const spansPromise = collectStreamedSpans('nuxt-5', spans => spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'), ); @@ -70,3 +74,50 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( }), }); }); + +test('sends an application render span and a root component span on pageload', async ({ page }) => { + // Same root cause as above: no Options API, no `app.mixin()`, no UI spans. Flips to passing once + // the root spans stop depending on the mixin. + test.fail(true, 'Vue tracing is registered through app.mixin(), which needs the Options API'); + + const spansPromise = collectStreamedSpans('nuxt-5', spans => + spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'), + ); + + await page.goto(`/client-error`); + + const spans = await spansPromise; + const uiSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ui.vue'); + + const applicationRenderSpans = uiSpans.filter(span => span.name === 'Application Render'); + expect(applicationRenderSpans).toHaveLength(1); + expect(applicationRenderSpans[0]).toMatchObject({ + name: 'Application Render', + is_segment: false, + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.render' }, + 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + }), + }); + + const rootComponentSpans = uiSpans.filter(span => span.name === 'Vue '); + expect(rootComponentSpans).toHaveLength(1); + expect(rootComponentSpans[0]).toMatchObject({ + name: 'Vue ', + is_segment: false, + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'ui.mount' }, + 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + }), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts index aa9c4b576612..684cf1e778f5 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts @@ -138,6 +138,68 @@ test('sends a pageload transaction with a route name as transaction name if avai }); }); +// The root component is always tracked, even when the route's view is missing from `trackComponents`. +// The root itself mounts synchronously on both routes (`app.mount()` does not wait for the router). +// What differs on `/components` is that its view arrives through a dynamic `import()`, so the +// async-loaded components must join the same pageload while `Application Render` is still open. +[ + { + route: '/', + routeDescription: 'a route with a synchronously mounted component', + // `HomeView` is missing from `trackComponents`, so the root spans are the only UI spans. + expectedUiSpanDescriptions: ['Application Render', 'Vue '], + }, + { + route: '/components', + routeDescription: 'a route with an async component', + expectedUiSpanDescriptions: [ + 'Application Render', + 'Vue ', + 'Vue ', + 'Vue ', + ], + }, +].forEach(({ route, routeDescription, expectedUiSpanDescriptions }) => { + test(`sends an application render span and a root component span on ${routeDescription}`, async ({ page }) => { + // Vue compiles `app.mixin()` down to a no-op when the Options API is disabled, so the SDK creates no UI spans at all. + test.fail(OPTIONS_API_DISABLED, 'Vue tracing is registered through app.mixin(), which needs the Options API'); + + const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.contexts?.trace?.data?.['url.path'] === route + ); + }); + + await page.goto(route); + + const rootSpan = await transactionPromise; + const uiSpans = (rootSpan.spans || []).filter(span => span.origin === 'auto.ui.vue'); + + expect(uiSpans.map(span => span.description).sort()).toEqual(expectedUiSpanDescriptions); + + const applicationRenderSpan = uiSpans.find(span => span.description === 'Application Render'); + expect(applicationRenderSpan).toMatchObject({ + data: { + 'sentry.op': 'ui.render', + 'sentry.origin': 'auto.ui.vue', + }, + op: 'ui.render', + origin: 'auto.ui.vue', + }); + + const rootComponentSpan = uiSpans.find(span => span.description === 'Vue '); + expect(rootComponentSpan).toMatchObject({ + data: { + 'sentry.op': 'ui.mount', + 'sentry.origin': 'auto.ui.vue', + }, + op: 'ui.mount', + origin: 'auto.ui.vue', + }); + }); +}); + test('sends a lifecycle span for the root and for each tracked component only', async ({ page }) => { // Vue compiles `app.mixin()` down to a no-op when the Options API is disabled, so the SDK creates no UI spans at all. test.fail(OPTIONS_API_DISABLED, 'Vue tracing is registered through app.mixin(), which needs the Options API'); diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 3a859415178e..bef3c07891db 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -5,8 +5,8 @@ import { spanToJSON } from '@sentry/core'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it as baseIt, vi } from 'vitest'; -import type { App } from 'vue'; -import { createApp, h } from 'vue'; +import type { App, Ref } from 'vue'; +import { createApp, h, nextTick, ref } from 'vue'; import * as Sentry from '../../src'; import type { Options, TracingOptions } from '../../src/types'; @@ -19,6 +19,7 @@ const SENTRY_ORIGIN_ATTRIBUTE = 'sentry.origin'; const VUE_SPAN_ORIGIN = 'auto.ui.vue'; const UI_MOUNT_SPAN_OP = 'ui.mount'; const UI_RENDER_SPAN_OP = 'ui.render'; +const UI_UPDATE_SPAN_OP = 'ui.update'; interface UiSpan { name: string; @@ -34,6 +35,14 @@ function createTestApp(): App { return createApp({ name: 'RootComponent', render: () => h('div', [h(child)]) }); } +/** Like `createTestApp`, but the root render reads a ref, so mutating it re-renders the root. */ +function createReactiveTestApp(): { app: App; message: Ref } { + const message = ref('initial message'); + const child = { name: 'ChildComponent', render: () => h('p', 'child') }; + const app = createApp({ name: 'RootComponent', render: () => h('div', [h('span', message.value), h(child)]) }); + return { app, message }; +} + /** Reads the mixins Vue accepted. `app.mixin()` is a silent no-op without the Options API. */ function getRegisteredMixins(app: App): unknown[] { return (app as unknown as { _context: { mixins: unknown[] } })._context.mixins; @@ -173,6 +182,72 @@ describe('tracing mixin span creation', () => { ]); }); + // The mixin always tracks the root component: `isRootComponent || …` short-circuits before the + // `trackComponents` filter runs. The next four tests record what that means for each hook, so a + // mixin replacement can prove which parts it keeps. + + it('tracks the root component for update hooks without trackComponents', async ({ uiSpans, initSentry }) => { + const { app, message } = createReactiveTestApp(); + initSentry({ tracing: { hooks: ['update'] }, sdk: { app } }); + const container = document.createElement('div'); + + await Sentry.startSpan({ name: 'pageload' }, async () => { + app.mount(container); + message.value = 'updated message'; + // Works under fake timers: Vue flushes re-renders through microtasks, not timers. + await nextTick(); + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1); + }); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Vue ', op: UI_UPDATE_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + // `beforeCreate` fires very early in `app.mount()`, but the mixin creates the root render span + // first, in the same handler. So the `create` span has a parent and is emitted, as `ui.mount`, + // which is the op the `create` operation maps to. + it('tracks the root component for create hooks without trackComponents', ({ app, uiSpans, initSentry }) => { + initSentry({ tracing: { hooks: ['create'] } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, // create + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, // mount (DEFAULT_HOOKS is always merged in) + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + // `activate` maps to the `activated`/`deactivated` hooks, which Vue only calls inside + // ``. A root component is never kept alive, so `activate` produces no root span. + it('does not track the root component for activate hooks', ({ app, uiSpans, initSentry }) => { + initSentry({ tracing: { hooks: ['activate'] } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + // Component spans are keyed by operation, not by span op, so `create` and `mount` each emit their + // own root span even though both map to `ui.mount`. + it('emits two root mount spans when both create and mount hooks are enabled', ({ app, uiSpans, initSentry }) => { + initSentry({ tracing: { hooks: ['create', 'mount'] } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, // create + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, // mount + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + // Vue 3 compiles `app.mixin()` down to a no-op returning the app when the `__VUE_OPTIONS_API__` // build flag is `false`. Nuxt 5 sets that flag by default (nuxt/nuxt#35791), so this stub matches // what those users run. The real build is covered by the `vue-3 (no Options API)` e2e variant.