From 8e463b10c09d197a26b70dfdc188dfd1f08d261b Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:30:56 +0200 Subject: [PATCH 1/4] feat(vue,nuxt): Record default UI spans without Options API (mixins) (#24174) Nuxt 5 disables Vue's Options API by default (nuxt/nuxt#35791). That turns `app.mixin()` into a silent no-op, and every Vue UI span comes from that mixin: `Application Render` and `Vue ` disappear with no signal. When the mixin is dropped, the SDK now wraps `app.mount()` and calls the same mixin hook bodies, so the default spans stay identical. The Nuxt SDK additionally extends the root span until `` resolves. `trackComponents` still needs the Options API and warns when it is lost. Vue 2 behavior stays unchanged (always needs Options API as it's not possible otherwise). Closes https://github.com/getsentry/sentry-javascript/issues/23375 Docs follow-up: https://github.com/getsentry/sentry-docs/issues/19290 --- .../nuxt-5/tests/tracing.client.test.ts | 43 ++ .../test-applications/vue-3/package.json | 7 + .../vue-3/src/router/index.ts | 6 + .../vue-3/src/views/DelayedView.vue | 26 + .../vue-3/tests/performance.test.ts | 88 ++++ .../test-applications/vue-3/vite.config.ts | 6 + packages/vue/src/constants.ts | 3 + packages/vue/src/integration.ts | 53 ++- packages/vue/src/rootInstrumentation.ts | 51 ++ packages/vue/src/tracing.ts | 8 +- .../integration/mixinRegistration.test.ts | 448 ++++++++++++++++++ 11 files changed, 731 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue create mode 100644 packages/vue/src/rootInstrumentation.ts create mode 100644 packages/vue/test/integration/mixinRegistration.test.ts 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 f4a53b1b4fb7..41b14074daf8 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 @@ -70,6 +70,10 @@ test('sends a navigation root span with a parameterized URL', async ({ page }) = }); test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { + // Nuxt 5 disables the Options API by default (nuxt/nuxt#35791), and component spans only exist + // through `app.mixin()`, which that flag turns into a no-op. `vue: { optionsApi: true }` re-enables it. + test.fail(true, 'Component tracking (`trackComponents`) needs the Options API'); + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { return transactionEvent.transaction === '/client-error'; }); @@ -93,3 +97,42 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( expect(errorButtonSpan).toMatchObject(expected); }); + +test('sends an application render span and a root component span on pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/client-error'; + }); + + await page.goto(`/client-error`); + + const rootSpan = await transactionPromise; + const uiSpans = (rootSpan.spans ?? []).filter((span: Span) => span.origin === 'auto.ui.vue'); + + const applicationRenderSpans = uiSpans.filter((span: Span) => span.description === 'Application Render'); + expect(applicationRenderSpans).toHaveLength(1); + expect(applicationRenderSpans[0]).toMatchObject({ + data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.vue.render' }, + description: 'Application Render', + op: 'ui.vue.render', + origin: 'auto.ui.vue', + 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), + timestamp: expect.any(Number), + }); + + const rootComponentSpans = uiSpans.filter((span: Span) => span.description === 'Vue '); + expect(rootComponentSpans).toHaveLength(1); + expect(rootComponentSpans[0]).toMatchObject({ + data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.vue.mount' }, + description: 'Vue ', + op: 'ui.vue.mount', + origin: 'auto.ui.vue', + 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), + timestamp: expect.any(Number), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json index 7367355d8f1e..be087c8d397d 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -15,6 +15,8 @@ "test:assert": "pnpm test:print-version && playwright test", "test:build-canary": "pnpm install && pnpm test:install-canary && pnpm build", "test:build-latest": "pnpm install && pnpm add vue@latest && pnpm build", + "test:build-no-options-api": "pnpm install && VUE_OPTIONS_API=false pnpm build", + "test:assert-no-options-api": "VUE_OPTIONS_API=false pnpm test:assert", "test:install-canary": "pnpm add vue@$(git ls-remote --tags --sort='v:refname' https://github.com/vuejs/core.git | tail -n1 | awk -F'/' '{print $NF}')", "test:print-version": "node -p \"'Vue version: ' + require('vue/package.json').version\"" }, @@ -52,6 +54,11 @@ { "build-command": "pnpm test:build-canary", "label": "vue-3 (canary)" + }, + { + "build-command": "pnpm test:build-no-options-api", + "assert-command": "pnpm test:assert-no-options-api", + "label": "vue-3 (no Options API)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts index c81a662c61e2..030d75dffb23 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router'; +import DelayedView from '../views/DelayedView.vue'; import HomeView from '../views/HomeView.vue'; const router = createRouter({ @@ -8,6 +9,11 @@ const router = createRouter({ path: '/', component: HomeView, }, + { + // Loaded eagerly so the only async step on this route is the view's delayed child component. + path: '/delayed', + component: DelayedView, + }, { path: '/about', name: 'AboutView', diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue new file mode 100644 index 000000000000..c5dad2ae5e76 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue @@ -0,0 +1,26 @@ + + + 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 a1a9626d01cf..63fbeb0390bf 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 @@ -1,6 +1,12 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +// Set by the `assert-command` of the `vue-3 (no Options API)` variant +const OPTIONS_API_DISABLED = process.env.VUE_OPTIONS_API === 'false'; + +// Must stay in sync with `ASYNC_CHILD_DELAY_MS` in `src/views/DelayedView.vue`. +const ASYNC_CHILD_DELAY_S = 0.3; + test('sends a pageload transaction with a parameterized URL', async ({ page }) => { const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; @@ -135,7 +141,89 @@ test('sends a pageload transaction with a route name as transaction name if avai }); }); +// The root component is always tracked, and the `app.mount()` wrap records the root spans when +// the Options API is disabled, so both variants expect them. The tracked component spans on +// `/components` still need the Options API, so the disabled variant expects the root spans only. +[ + { + route: '/', + routeDescription: 'a route with a synchronously mounted component', + // `HomeView` is missing from `trackComponents`, so the root spans are the only UI spans. + expectedUiSpanNames: ['Application Render', 'Vue '].sort(), + }, + { + route: '/components', + routeDescription: 'a route with an async component', + expectedUiSpanNames: OPTIONS_API_DISABLED + ? ['Application Render', 'Vue '].sort() + : ['Application Render', 'Vue ', 'Vue ', 'Vue '].sort(), + }, +].forEach(({ route, routeDescription, expectedUiSpanNames }) => { + test(`sends an application render span and a root component span on ${routeDescription}`, async ({ page }) => { + 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(expectedUiSpanNames); + + const applicationRenderSpan = uiSpans.find(span => span.description === 'Application Render'); + expect(applicationRenderSpan).toMatchObject({ + description: 'Application Render', + op: 'ui.vue.render', + origin: 'auto.ui.vue', + }); + + const rootComponentSpan = uiSpans.find(span => span.description === 'Vue '); + expect(rootComponentSpan).toMatchObject({ + description: 'Vue ', + op: 'ui.vue.mount', + origin: 'auto.ui.vue', + }); + }); +}); + +// True on both variants: the mixin arms one debounce timer per component (`tracing.ts`), so a +// late child never clears the root's earlier timer and the span ends at the root's mount. The +// `app.mount()` wrap only observes the root, so it matches. +test('ends the application render span before a delayed async component mounts', async ({ page }) => { + const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.contexts?.trace?.data?.['url.path'] === '/delayed' + ); + }); + + await page.goto('/delayed'); + // Proves the child really mounted after its delay; the duration assertion relies on it. + await expect(page.locator('#delayed-child')).toBeVisible(); + + const rootSpan = await transactionPromise; + const uiSpans = (rootSpan.spans ?? []).filter(span => span.origin === 'auto.ui.vue'); + + // Neither `DelayedView` nor its child is in `trackComponents`, so both variants expect the same set. + expect(uiSpans.map(span => span.description).sort()).toEqual(['Application Render', 'Vue ']); + + const applicationRenderSpan = uiSpans.find(span => span.description === 'Application Render'); + expect(applicationRenderSpan?.start_timestamp).toEqual(expect.any(Number)); + expect(applicationRenderSpan?.timestamp).toEqual(expect.any(Number)); + + const duration = (applicationRenderSpan?.timestamp ?? 0) - (applicationRenderSpan?.start_timestamp ?? 0); + expect(duration).toBeLessThan(ASYNC_CHILD_DELAY_S); +}); + test('sends a lifecycle span for each tracked components', async ({ page }) => { + // The root spans survive through the `app.mount()` wrap, but the tracked component spans asserted + // below still come from `app.mixin()`, which is a no-op when the Options API is disabled. + test.fail(OPTIONS_API_DISABLED, 'Component tracking (`trackComponents`) needs the Options API'); + const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts index 72a15caeae52..e385ddeaadee 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts @@ -4,9 +4,15 @@ import vue from '@vitejs/plugin-vue'; import vueJsx from '@vitejs/plugin-vue-jsx'; import { defineConfig } from 'vite'; +// Nuxt 5 disables the Options API by default (users can disable it too for smaller bundle size) +const optionsApi = process.env.VUE_OPTIONS_API === 'false' ? 'false' : 'true'; + // https://vitejs.dev/config/ export default defineConfig({ plugins: [vue(), vueJsx()], + define: { + __VUE_OPTIONS_API__: optionsApi, + }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), diff --git a/packages/vue/src/constants.ts b/packages/vue/src/constants.ts index 50aa82f77885..fc62410bd823 100644 --- a/packages/vue/src/constants.ts +++ b/packages/vue/src/constants.ts @@ -1,3 +1,6 @@ import type { Operation } from './types'; export const DEFAULT_HOOKS: Operation[] = ['activate', 'mount']; + +/** How long the root render span waits for further render activity before it ends. */ +export const DEFAULT_ROOT_SPAN_TIMEOUT = 2000; diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index f4d3b2480d50..8bdac3d5db44 100644 --- a/packages/vue/src/integration.ts +++ b/packages/vue/src/integration.ts @@ -1,9 +1,10 @@ import { consoleSandbox, defineIntegration, GLOBAL_OBJ, hasSpansEnabled } from '@sentry/core'; -import { DEFAULT_HOOKS } from './constants'; +import { DEFAULT_HOOKS, DEFAULT_ROOT_SPAN_TIMEOUT } from './constants'; import { DEBUG_BUILD } from './debug-build'; import { attachErrorHandler } from './errorhandler'; +import { instrumentAppMountWithoutMixin } from './rootInstrumentation'; import { createTracingMixins } from './tracing'; -import type { Options, Vue, VueOptions } from './types'; +import type { Options, TracingOptions, Vue, VueOptions } from './types'; const globalWithVue = GLOBAL_OBJ as typeof GLOBAL_OBJ & { Vue: Vue }; @@ -13,7 +14,7 @@ const DEFAULT_CONFIG: VueOptions = { attachErrorHandler: true, tracingOptions: { hooks: DEFAULT_HOOKS, - timeout: 2000, + timeout: DEFAULT_ROOT_SPAN_TIMEOUT, trackComponents: false, }, }; @@ -74,6 +75,50 @@ const vueInit = (app: Vue, options: Options): void => { } if (hasSpansEnabled(options)) { - app.mixin(createTracingMixins(options.tracingOptions)); + const mixins = createTracingMixins(options.tracingOptions); + app.mixin(mixins); + if (!mixinWasApplied(app, mixins)) { + instrumentAppMountWithoutMixin(app, mixins); + warnAboutLostComponentTracking(app, options.tracingOptions); + } } }; + +/** + * Reads back whether Vue accepted the mixin, because `app.mixin()` fails silently when the Options + * API is disabled (the Nuxt 5 default). A Vue 2 constructor has no `_context` and no Options API + * flag, so the mixin always applies there. + * + * See: https://github.com/vuejs/core/blob/v3.5.41/packages/runtime-core/src/apiCreateApp.ts + */ +function mixinWasApplied(app: Vue, mixin: unknown): boolean { + const mixins = (app as Vue & { _context?: { mixins?: unknown[] } })._context?.mixins; + return !mixins || mixins.includes(mixin); +} + +/** + * Warns only when the dropped mixin loses component tracking the user opted into. The default + * spans still work through the `app.mount()` wrap, so a default config stays silent. + */ +function warnAboutLostComponentTracking(app: Vue, tracingOptions: Partial | undefined): void { + const trackComponents = tracingOptions?.trackComponents; + const losesComponentSpans = + trackComponents === true || (Array.isArray(trackComponents) && trackComponents.length > 0); + + if (!losesComponentSpans) { + return; + } + + // `createNuxtApp()` sets `$nuxt` on the Vue app before it calls the `app:created` hook (where Nuxt SDK adds this integration). + const fix = + '$nuxt' in app + ? 'Set `vue: { optionsApi: true }` in your `nuxt.config.ts`.' + : 'Set `__VUE_OPTIONS_API__` to `true` in the `define` config of your bundler.'; + + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + `[@sentry/vue]: The Vue Options API is disabled (\`__VUE_OPTIONS_API__: false\`). Sentry still records the \`Application Render\` and root component mount spans, but component tracking (\`trackComponents\`) needs the Options API. ${fix}`, + ); + }); +} diff --git a/packages/vue/src/rootInstrumentation.ts b/packages/vue/src/rootInstrumentation.ts new file mode 100644 index 000000000000..4f0e30dd2b2c --- /dev/null +++ b/packages/vue/src/rootInstrumentation.ts @@ -0,0 +1,51 @@ +import type { Mixins, VueSentry } from './tracing'; +import type { Vue } from './types'; + +const instrumentedApps = new WeakSet(); + +/** + * The mixin hooks only check `$root === this` to detect the root component, so a self-referential + * stand-in works in place of the real instance, which does not exist yet at wrap time. + */ +function createRootViewModel(): VueSentry { + const vm: { $root?: unknown; $props: Record } = { $props: {} }; + vm.$root = vm; + return vm as unknown as VueSentry; +} + +/** + * Records the `Application Render` and root component mount spans by wrapping `app.mount()`, for + * builds where the Options API is compiled out and `app.mixin()` is a silent no-op (Nuxt 5 default). + * + * Vue runs all `mounted` hooks before `mount()` returns, so the wrap covers the same window as the + * mixin's root hooks. Late mounts extend neither path; the mixin's debounce timers are per component. + */ +export function instrumentAppMountWithoutMixin(app: Vue, mixins: Mixins): void { + // A second wrap would duplicate the root spans (e.g. user and Nuxt SDK both add the integration). + if (instrumentedApps.has(app)) { + return; + } + + const appWithMount = app as Vue & { mount?: (...args: unknown[]) => unknown }; + const originalMount = appWithMount.mount; + // Guards odd app-like objects; Vue 2 constructors lack `mount` but never get here (their `app.mixin()` works). + if (typeof originalMount !== 'function') { + return; + } + + const vm = createRootViewModel(); + instrumentedApps.add(app); + + // `createTracingMixins` always merges `DEFAULT_HOOKS`, so the `mount` pair exists. + const mountHooks = mixins as Partial void>>; + + appWithMount.mount = function (...args: unknown[]): unknown { + mountHooks.beforeMount?.call(vm); + try { + return originalMount.apply(this, args); + } finally { + // Also runs when mounting throws, so the started root component span always ends. + mountHooks.mounted?.call(vm); + } + }; +} diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index f33c998701eb..a1e43fb357da 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -1,16 +1,16 @@ import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/browser'; import type { Span } from '@sentry/core'; import { debug, timestampInSeconds, uniq } from '@sentry/core'; -import { DEFAULT_HOOKS } from './constants'; +import { DEFAULT_HOOKS, DEFAULT_ROOT_SPAN_TIMEOUT } from './constants'; import { DEBUG_BUILD } from './debug-build'; import type { Hook, Operation, TracingOptions, ViewModel, Vue } from './types'; import { formatComponentName } from './vendor/components'; const VUE_OP = 'ui.vue'; -type Mixins = Parameters[0]; +export type Mixins = Parameters[0]; -interface VueSentry extends ViewModel { +export interface VueSentry extends ViewModel { readonly $root: VueSentry; $_sentryComponentSpans?: { [key: string]: Span | undefined; @@ -63,7 +63,7 @@ export const createTracingMixins = (options: Partial = {}): Mixi const mixins: Mixins = {}; - const rootComponentSpanFinalTimeout = options.timeout || 2000; + const rootComponentSpanFinalTimeout = options.timeout || DEFAULT_ROOT_SPAN_TIMEOUT; for (const operation of hooks) { // Retrieve corresponding hooks from Vue lifecycle. diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts new file mode 100644 index 000000000000..8539a2ec1818 --- /dev/null +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -0,0 +1,448 @@ +/** + * @vitest-environment jsdom + */ + +import { spanToJSON } from '@sentry/core'; +import type { MockInstance } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it as baseIt, vi } from 'vitest'; +import type { App, Component, Ref } from 'vue'; +import { createApp, defineAsyncComponent, h, nextTick, ref } from 'vue'; +import * as Sentry from '../../src'; +import type { Options, TracingOptions } from '../../src/types'; + +const PUBLIC_DSN = 'https://username@domain/123'; +const ROOT_SPAN_TIMEOUT_MS = 100; + +// Hardcoded instead of derived from the SDK source to catch changed OPs +const VUE_SPAN_ORIGIN = 'auto.ui.vue'; +const UI_MOUNT_SPAN_OP = 'ui.vue.mount'; +const UI_RENDER_SPAN_OP = 'ui.vue.render'; +const UI_UPDATE_SPAN_OP = 'ui.vue.update'; +const UI_CREATE_SPAN_OP = 'ui.vue.create'; + +interface UiSpan { + name: string; + op: unknown; +} + +/** + * A two-component app where `` renders ``. Both use render functions, so the + * tree mounts the same way with and without the Options API. + */ +function createTestApp(): App { + const child = { name: 'ChildComponent', render: () => h('p', 'child') }; + 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 }; +} + +/** An app whose only child mounts when the returned `resolveChild` is called. */ +function createAppWithDeferredChild(): { app: App; resolveChild: () => void } { + let resolve: (component: Component) => void = () => {}; + const asyncChild = defineAsyncComponent( + () => + new Promise(resolveLoader => { + resolve = resolveLoader; + }), + ); + const app = createApp({ name: 'RootComponent', render: () => h('div', [h(asyncChild)]) }); + return { app, resolveChild: () => resolve({ render: () => h('p', 'child') }) }; +} + +/** 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; +} + +/** Mounts `app` inside an active root span, because the Vue UI spans use `onlyIfParent`. */ +function mountUnderActiveSpan(app: App): HTMLElement { + const container = document.createElement('div'); + + Sentry.startSpan({ name: 'pageload' }, () => { + app.mount(container); + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1); + }); + + return container; +} + +interface Fixtures { + fakeTimers: void; + app: App; + /** Vue UI spans that ended, in the order they ended. `initSentry` starts the recording. */ + uiSpans: UiSpan[]; + initSentry: (overrides?: { tracing?: Partial; sdk?: Partial }) => void; +} + +// Vitest reads the destructured keys of the first parameter to resolve fixture dependencies, so a +// fixture without dependencies has to spell out an empty pattern. +/* oxlint-disable no-empty-pattern */ +const it = baseIt.extend({ + // The root render span ends on a debounce, so the tests need to drive the clock. + fakeTimers: [ + async ({}, use) => { + vi.useFakeTimers(); + await use(); + vi.useRealTimers(); + }, + { auto: true }, + ], + + app: async ({}, use) => { + await use(createTestApp()); + }, + + uiSpans: async ({}, use) => { + await use([]); + }, + + initSentry: async ({ app, uiSpans }, use) => { + await use(({ tracing, sdk } = {}) => { + const client = Sentry.init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + tracesSampleRate: 1, + app, + integrations: [Sentry.vueIntegration({ tracingOptions: { timeout: ROOT_SPAN_TIMEOUT_MS, ...tracing } })], + ...sdk, + }); + + client?.on('spanEnd', span => { + const { description, op, origin } = spanToJSON(span); + if (origin === VUE_SPAN_ORIGIN) { + uiSpans.push({ name: description ?? '', op }); + } + }); + }); + }, +}); +/* oxlint-enable no-empty-pattern */ + +describe('tracing mixin registration', () => { + it('registers the tracing mixin on the app when tracing is enabled', ({ app, initSentry }) => { + initSentry(); + + expect(getRegisteredMixins(app)).toHaveLength(1); + }); + + it('registers no mixin when tracing is disabled', ({ app, initSentry }) => { + initSentry({ sdk: { tracesSampleRate: undefined } }); + + expect(getRegisteredMixins(app)).toEqual([]); + }); + + it('registers the tracing mixin on every app when an array of apps is passed', ({ initSentry }) => { + const firstApp = createTestApp(); + const secondApp = createTestApp(); + + initSentry({ sdk: { app: [firstApp, secondApp] } }); + + expect(getRegisteredMixins(firstApp)).toHaveLength(1); + expect(getRegisteredMixins(secondApp)).toHaveLength(1); + }); + + it('registers the tracing mixin on the constructor passed as `Vue` (Vue 2 setup)', ({ app, initSentry }) => { + initSentry({ sdk: { app: undefined, Vue: app } }); + + expect(getRegisteredMixins(app)).toHaveLength(1); + }); +}); + +describe('tracing mixin span creation', () => { + it('creates a root render span and a root component span when trackComponents is off', ({ + app, + uiSpans, + initSentry, + }) => { + initSentry(); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('creates a component span for a name listed in trackComponents', ({ app, uiSpans, initSentry }) => { + initSentry({ tracing: { trackComponents: ['ChildComponent'] } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('creates no component span for a name missing from trackComponents', ({ app, uiSpans, initSentry }) => { + initSentry({ tracing: { trackComponents: ['SomeOtherComponent'] } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + // The mixin always tracks the root component: `isRootComponent || …` short-circuits before the + // `trackComponents` filter runs. The following tests record what that means for each hook. + + 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 }, + ]); + }); + + // The mixin creates the root render span in the same `beforeCreate` handler, so the `create` + // span has a parent even this early in `app.mount()`. + 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_CREATE_SPAN_OP }, + { 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, so `create` and `mount` each emit their own root span. + it('emits two root lifecycle 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_CREATE_SPAN_OP }, + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + // `maybeEndRootComponentSpan` arms one debounce timer per component, so a late child never + // clears the root's earlier timer, and the root's timer ends the span first. The twin test in + // the disabled describe below proves the `app.mount()` wrap matches. + it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => { + const { app } = createAppWithDeferredChild(); + initSentry({ sdk: { app } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { 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. + describe('when the Options API is disabled', () => { + function disableOptionsApi(app: App): void { + app.mixin = () => app; + } + + it('creates the same UI spans as with the Options API enabled', ({ app, uiSpans, initSentry }) => { + disableOptionsApi(app); + initSentry(); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('mounts the component tree', ({ app, initSentry }) => { + disableOptionsApi(app); + initSentry(); + + const container = mountUnderActiveSpan(app); + + expect(container.innerHTML).toBe('

child

'); + }); + + // Users rely on `const instance = app.mount(container)`; the wrap must not swallow it. + it('returns the root instance from the wrapped mount', ({ app, initSentry }) => { + disableOptionsApi(app); + initSentry(); + const container = document.createElement('div'); + + const rootInstance = app.mount(container); + + expect(rootInstance.$el).toBe(container.firstElementChild); + }); + + // Matches the mixin-path twin above: the mixin never waited for late children either. + it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => { + const { app } = createAppWithDeferredChild(); + disableOptionsApi(app); + initSentry({ sdk: { app } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('records no further spans when a child mounts after the root span ended', async ({ uiSpans, initSentry }) => { + const { app, resolveChild } = createAppWithDeferredChild(); + disableOptionsApi(app); + initSentry({ sdk: { app } }); + const container = mountUnderActiveSpan(app); + + resolveChild(); + // Async component resolution hops through several real microtasks before the re-render + // flush, so poll until the child rendered; `vi.waitFor` advances the fake timers itself. + await vi.waitFor(() => expect(container.innerHTML).toBe('

child

')); + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1); + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('attaches the Vue error handler', ({ app, initSentry }) => { + disableOptionsApi(app); + + initSentry(); + + expect(app.config.errorHandler).toBeDefined(); + }); + }); +}); + +describe('Options API detection guard', () => { + const OPTIONS_API_WARNING = expect.stringContaining('The Vue Options API is disabled'); + + let consoleWarn: MockInstance; + + beforeEach(() => { + consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarn.mockRestore(); + }); + + // The default spans survive without the mixin (see the fallback tests above), so warning about a + // default config would be noise on every Nuxt 5 app. + it('does not warn with default options when the app dropped the tracing mixin', ({ app, initSentry }) => { + app.mixin = () => app; + + initSentry(); + + expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); + + it('warns when trackComponents is enabled and the app dropped the tracing mixin', ({ app, initSentry }) => { + app.mixin = () => app; + + initSentry({ tracing: { trackComponents: true } }); + + expect(consoleWarn).toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); + + it('warns when a tracked component list is configured and the app dropped the tracing mixin', ({ + app, + initSentry, + }) => { + app.mixin = () => app; + + initSentry({ tracing: { trackComponents: ['ChildComponent'] } }); + + expect(consoleWarn).toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); + + // A custom `hooks` config also degrades without the mixin, but the fallback still covers + // `mount` for the root, so only `trackComponents` is worth a warning. + it('does not warn when only hooks are configured', ({ app, initSentry }) => { + app.mixin = () => app; + + initSentry({ tracing: { hooks: ['update'] } }); + + expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); + + it('points a plain Vue app at its bundler config', ({ app, initSentry }) => { + app.mixin = () => app; + + initSentry({ tracing: { trackComponents: true } }); + + expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining('`define` config of your bundler')); + expect(consoleWarn).not.toHaveBeenCalledWith(expect.stringContaining('nuxt.config.ts')); + }); + + // `createNuxtApp()` sets `$nuxt` on the Vue app before the `app:created` hook the Nuxt SDK uses. + it('points a Nuxt app at `nuxt.config.ts`', ({ app, initSentry }) => { + app.mixin = () => app; + Object.defineProperty(app, '$nuxt', { get: () => ({}) }); + + initSentry({ tracing: { trackComponents: true } }); + + expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining('`vue: { optionsApi: true }`')); + }); + + it('does not warn when the app accepted the tracing mixin', ({ initSentry }) => { + initSentry({ tracing: { trackComponents: true } }); + + expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); + + it('does not warn when tracing is disabled, because no mixin is registered', ({ app, initSentry }) => { + app.mixin = () => app; + + initSentry({ tracing: { trackComponents: true }, sdk: { tracesSampleRate: undefined } }); + + expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); + + // A Vue 2 constructor has no `_context`, so the guard has nothing to read back and must stay quiet. + it('does not warn for a Vue 2 constructor', ({ initSentry }) => { + const vue2Constructor = { config: {}, mixin: () => {} }; + + initSentry({ tracing: { trackComponents: true }, sdk: { app: undefined, Vue: vue2Constructor } }); + + expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); +}); From d6a01607683ea97323c202a0250c00e2e218e727 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:09:24 +0200 Subject: [PATCH 2/4] fix(vue): Share one root render span debounce timer across components (#24868) The tracing mixin stored its debounce timer on the calling component while ending the span on `$root`, so every mounted component started its own 2-second timer. All debounce state now lives on `$root`: activity is a property write with one shared timer. The span ends at the last render activity, so late-mounting children now extend it when the mixins API is used (the intent of #16488). There's a behavior difference between mixins and the `app.mount()` wrap (for components that mount later): Without the Options API there is no way to hook into child component lifecycles, so the `app.mount()` wrap can only observe the root and late children (async components, `Suspense`children) extend the span only when mixins are used. Fixes #24858 --- .../vue-3/src/views/DelayedView.vue | 6 +- .../vue-3/tests/performance.test.ts | 14 ++-- packages/vue/src/rootInstrumentation.ts | 3 +- packages/vue/src/tracing.ts | 40 +++++++-- .../integration/mixinRegistration.test.ts | 82 ++++++++++++++++++- .../vue/test/tracing/tracingMixin.test.ts | 57 +++++++++++++ 6 files changed, 182 insertions(+), 20 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue index c5dad2ae5e76..f426ab742fca 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue @@ -5,8 +5,10 @@ import { defineAsyncComponent, h } from 'vue'; // Must stay in sync with `ASYNC_CHILD_DELAY_S` in `tests/performance.test.ts`. const ASYNC_CHILD_DELAY_MS = 300; -// A child that mounts a fixed delay after the rest of the page, so tests can prove the -// `Application Render` span does not wait for late children on either instrumentation path. +// A child that mounts a fixed delay after the rest of the page, so tests can prove how each +// instrumentation path treats late children: +// - mixin: extends the `Application Render` span until the child mounts +// - `app.mount()` wrap: does not observe late children const DelayedChild = defineAsyncComponent( () => new Promise(resolve => { 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 63fbeb0390bf..778e0435785d 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 @@ -190,10 +190,13 @@ test('sends a pageload transaction with a route name as transaction name if avai }); }); -// True on both variants: the mixin arms one debounce timer per component (`tracing.ts`), so a -// late child never clears the root's earlier timer and the span ends at the root's mount. The -// `app.mount()` wrap only observes the root, so it matches. -test('ends the application render span before a delayed async component mounts', async ({ page }) => { +// Mixin: records render activity from every component, so the late child pushes the root render span's end out to its own mount. +// `app.mount()` wrap: only observes the root, so without the Options API the span still ends before the child mounts. +const [minRenderSpanDuration, maxRenderSpanDuration] = OPTIONS_API_DISABLED + ? [0, ASYNC_CHILD_DELAY_S] + : [ASYNC_CHILD_DELAY_S, 10]; + +test('ends the application render span at the last observed render activity', async ({ page }) => { const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { return ( transactionEvent.contexts?.trace?.op === 'pageload' && @@ -216,7 +219,8 @@ test('ends the application render span before a delayed async component mounts', expect(applicationRenderSpan?.timestamp).toEqual(expect.any(Number)); const duration = (applicationRenderSpan?.timestamp ?? 0) - (applicationRenderSpan?.start_timestamp ?? 0); - expect(duration).toBeLessThan(ASYNC_CHILD_DELAY_S); + expect(duration).toBeGreaterThanOrEqual(minRenderSpanDuration); + expect(duration).toBeLessThan(maxRenderSpanDuration); }); test('sends a lifecycle span for each tracked components', async ({ page }) => { diff --git a/packages/vue/src/rootInstrumentation.ts b/packages/vue/src/rootInstrumentation.ts index 4f0e30dd2b2c..5b16a7346b4b 100644 --- a/packages/vue/src/rootInstrumentation.ts +++ b/packages/vue/src/rootInstrumentation.ts @@ -18,7 +18,8 @@ function createRootViewModel(): VueSentry { * builds where the Options API is compiled out and `app.mixin()` is a silent no-op (Nuxt 5 default). * * Vue runs all `mounted` hooks before `mount()` returns, so the wrap covers the same window as the - * mixin's root hooks. Late mounts extend neither path; the mixin's debounce timers are per component. + * mixin's root hooks. Late mounts extend the span only when mixins are used: they record render activity + * from every component, while the `app.mount()` wrap only observes the root component. */ export function instrumentAppMountWithoutMixin(app: Vue, mixins: Mixins): void { // A second wrap would duplicate the root spans (e.g. user and Nuxt SDK both add the integration). diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index a1e43fb357da..d93bee625630 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -17,6 +17,7 @@ export interface VueSentry extends ViewModel { }; $_sentryRootComponentSpan?: Span; $_sentryRootComponentSpanTimer?: ReturnType; + $_sentryRootComponentSpanActivity?: number; } // Mappings from operation to corresponding lifecycle hook. @@ -31,18 +32,41 @@ const HOOKS: { [key in Operation]: Hook[] } = { update: ['beforeUpdate', 'updated'], }; -/** End the top-level component span and activity with a debounce configured using `timeout` option */ +/** + * End the root component span once no render activity happened for `timeout` (a debounce). + * + * All debounce state lives on `$root`, so hooks from every component share one timer. Each component + * only writes a timestamp (no new timer is created). So mounting any number of components uses a single timer. + */ function maybeEndRootComponentSpan(vm: VueSentry, timestamp: number, timeout: number): void { - if (vm.$_sentryRootComponentSpanTimer) { - clearTimeout(vm.$_sentryRootComponentSpanTimer); + const root = vm.$root; + root.$_sentryRootComponentSpanActivity = timestamp; + + if (!root.$_sentryRootComponentSpanTimer) { + scheduleRootComponentSpanEnd(root, timestamp, timeout); } +} + +/** + * Fires `delayMs` after the activity that scheduled it. Activity recorded in the meantime pushes + * the deadline out by the recorded gap, so the span always ends at the last activity timestamp. + */ +function scheduleRootComponentSpanEnd(root: VueSentry, activityWhenScheduled: number, delayMs: number): void { + root.$_sentryRootComponentSpanTimer = setTimeout(() => { + root.$_sentryRootComponentSpanTimer = undefined; + + const lastActivity = root.$_sentryRootComponentSpanActivity ?? activityWhenScheduled; + if (lastActivity > activityWhenScheduled) { + // activity happened after this timer was scheduled: push the deadline out + scheduleRootComponentSpanEnd(root, lastActivity, (lastActivity - activityWhenScheduled) * 1000); + return; + } - vm.$_sentryRootComponentSpanTimer = setTimeout(() => { - if (vm.$root?.$_sentryRootComponentSpan) { - vm.$root.$_sentryRootComponentSpan.end(timestamp); - vm.$root.$_sentryRootComponentSpan = undefined; + if (root.$_sentryRootComponentSpan) { + root.$_sentryRootComponentSpan.end(lastActivity); + root.$_sentryRootComponentSpan = undefined; } - }, timeout); + }, delayMs); } /** Find if the current component exists in the provided `TracingOptions.trackComponents` array option. */ diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 8539a2ec1818..042c18c18742 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ +import type * as SentryCore from '@sentry/core'; import { spanToJSON } from '@sentry/core'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it as baseIt, vi } from 'vitest'; @@ -10,6 +11,13 @@ import { createApp, defineAsyncComponent, h, nextTick, ref } from 'vue'; import * as Sentry from '../../src'; import type { Options, TracingOptions } from '../../src/types'; +vi.mock('@sentry/core', async importOriginal => { + return { + ...(await importOriginal()), + timestampInSeconds: () => Date.now() / 1000, + }; +}); + const PUBLIC_DSN = 'https://username@domain/123'; const ROOT_SPAN_TIMEOUT_MS = 100; @@ -256,9 +264,8 @@ describe('tracing mixin span creation', () => { ]); }); - // `maybeEndRootComponentSpan` arms one debounce timer per component, so a late child never - // clears the root's earlier timer, and the root's timer ends the span first. The twin test in - // the disabled describe below proves the `app.mount()` wrap matches. + // The debounce only waits for recorded render activity, so a child that never mounts records + // none and cannot keep the root render span open. it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => { const { app } = createAppWithDeferredChild(); initSentry({ sdk: { app } }); @@ -271,6 +278,72 @@ describe('tracing mixin span creation', () => { ]); }); + it('extends the root render span while children keep mounting within the timeout', async ({ + uiSpans, + initSentry, + }) => { + const showChild = ref(false); + const child = { name: 'ChildComponent', render: () => h('p', 'child') }; + const app = createApp({ name: 'RootComponent', render: () => h('div', [showChild.value ? h(child) : null]) }); + initSentry({ sdk: { app } }); + const container = document.createElement('div'); + + await Sentry.startSpan({ name: 'pageload' }, async () => { + app.mount(container); + + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS - 1); + showChild.value = true; + await nextTick(); + + // The original deadline passes, but the child's mount pushed it out. + vi.advanceTimersByTime(2); + expect(uiSpans.map(span => span.op)).not.toContain(UI_RENDER_SPAN_OP); + + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1); + }); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('names UI spans after the component and preserves the original description when span streaming is enabled', ({ + app, + uiSpans, + initSentry, + }) => { + initSentry({ tracing: { trackComponents: ['ChildComponent'] }, sdk: { traceLifecycle: 'stream' } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'ChildComponent', op: UI_MOUNT_SPAN_OP }, + { name: 'Root', op: UI_MOUNT_SPAN_OP }, + { name: 'Root', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('uses the component mount fallback for anonymous components when span streaming is enabled', ({ + uiSpans, + initSentry, + }) => { + const anonymousChild = { render: () => h('p', 'child') }; + const app = createApp({ render: () => h('div', [h(anonymousChild)]) }); + initSentry({ + tracing: { trackComponents: true }, + sdk: { app, traceLifecycle: 'stream' }, + }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Component mount', op: UI_MOUNT_SPAN_OP }, + { name: 'Root', op: UI_MOUNT_SPAN_OP }, + { name: 'Root', 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. @@ -311,7 +384,8 @@ describe('tracing mixin span creation', () => { expect(rootInstance.$el).toBe(container.firstElementChild); }); - // Matches the mixin-path twin above: the mixin never waited for late children either. + // Unlike the mixin path, the `app.mount()` wrap only observes the root, so a late child can + // never extend the root render span here. it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => { const { app } = createAppWithDeferredChild(); disableOptionsApi(app); diff --git a/packages/vue/test/tracing/tracingMixin.test.ts b/packages/vue/test/tracing/tracingMixin.test.ts index 2c08a20c61cd..f4472833e40a 100644 --- a/packages/vue/test/tracing/tracingMixin.test.ts +++ b/packages/vue/test/tracing/tracingMixin.test.ts @@ -1,9 +1,18 @@ import { getActiveSpan, startInactiveSpan } from '@sentry/browser'; +import type * as SentryCore from '@sentry/core'; import type { Mock } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_HOOKS } from '../../src/constants'; import { createTracingMixins } from '../../src/tracing'; +const clock = vi.hoisted(() => ({ now: 0 })); +vi.mock('@sentry/core', async importOriginal => { + return { + ...(await importOriginal()), + timestampInSeconds: () => clock.now, + }; +}); + vi.mock('@sentry/browser', () => { return { getActiveSpan: vi.fn(), @@ -41,6 +50,7 @@ describe('Vue Tracing Mixins', () => { beforeEach(() => { vi.clearAllMocks(); + clock.now = 0; mockRootInstance = { $root: null, @@ -147,6 +157,53 @@ describe('Vue Tracing Mixins', () => { ); }); + describe('Root Span Debounce', () => { + it('arms a single shared timer on the root for any number of untracked components', () => { + const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 }); + + mixins.beforeMount.call(mockRootInstance); + for (let i = 0; i < 100; i++) { + const child = { $root: mockRootInstance, componentName: `ChildComponent${i}` }; + mixins.beforeMount.call(child); + mixins.mounted.call(child); + } + + expect(vi.getTimerCount()).toBe(1); + }); + + it('ends the root span at the last recorded activity, pushing the deadline out for late activity', () => { + const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 }); + clock.now = 10; + mixins.beforeMount.call(mockRootInstance); + const rootSpan = mockRootInstance.$_sentryRootComponentSpan; + + clock.now = 10.8; + mixins.mounted.call(mockVueInstance); + + vi.advanceTimersByTime(1000); + expect(rootSpan.end).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(800); + expect(rootSpan.end).toHaveBeenCalledWith(10.8); + }); + + it('records no span when a component mounts after the root span already ended', () => { + const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 }); + mixins.beforeMount.call(mockRootInstance); + const rootSpan = mockRootInstance.$_sentryRootComponentSpan; + vi.advanceTimersByTime(1001); + expect(rootSpan.end).toHaveBeenCalledTimes(1); + + clock.now = 5; + mixins.beforeMount.call(mockVueInstance); + mixins.mounted.call(mockVueInstance); + vi.advanceTimersByTime(1001); + + expect(rootSpan.end).toHaveBeenCalledTimes(1); + expect(mockRootInstance.$_sentryRootComponentSpan).toBeUndefined(); + }); + }); + describe('Component Span Lifecycle', () => { it('should create and end spans correctly through lifecycle hooks', () => { const mixins = createTracingMixins({ trackComponents: true }); From 02263c921335c97b75f0fd90b9f8c1bef4833859 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:11:38 +0200 Subject: [PATCH 3/4] fix tests --- .../integration/mixinRegistration.test.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 042c18c18742..dbf77bfba07a 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -308,26 +308,21 @@ describe('tracing mixin span creation', () => { ]); }); - it('names UI spans after the component and preserves the original description when span streaming is enabled', ({ - app, - uiSpans, - initSentry, - }) => { + + it('records UI spans when span streaming is enabled', ({ app, uiSpans, initSentry }) => { initSentry({ tracing: { trackComponents: ['ChildComponent'] }, sdk: { traceLifecycle: 'stream' } }); mountUnderActiveSpan(app); + // v10 does not have low-cardinality `ui` span names (only in v11) expect(uiSpans).toEqual([ - { name: 'ChildComponent', op: UI_MOUNT_SPAN_OP }, - { name: 'Root', op: UI_MOUNT_SPAN_OP }, - { name: 'Root', op: UI_RENDER_SPAN_OP }, + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, ]); }); - it('uses the component mount fallback for anonymous components when span streaming is enabled', ({ - uiSpans, - initSentry, - }) => { + it('records UI spans for anonymous components when span streaming is enabled', ({ uiSpans, initSentry }) => { const anonymousChild = { render: () => h('p', 'child') }; const app = createApp({ render: () => h('div', [h(anonymousChild)]) }); initSentry({ @@ -337,10 +332,11 @@ describe('tracing mixin span creation', () => { mountUnderActiveSpan(app); + // v10 does not have low-cardinality `ui` span names (only in v11) expect(uiSpans).toEqual([ - { name: 'Component mount', op: UI_MOUNT_SPAN_OP }, - { name: 'Root', op: UI_MOUNT_SPAN_OP }, - { name: 'Root', op: UI_RENDER_SPAN_OP }, + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, ]); }); From a6369d43cca33a48a4b49c046fbbe2418d09782c Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:23:08 +0200 Subject: [PATCH 4/4] fix formatting --- packages/vue/test/integration/mixinRegistration.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index dbf77bfba07a..263d041a6345 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -308,7 +308,6 @@ describe('tracing mixin span creation', () => { ]); }); - it('records UI spans when span streaming is enabled', ({ app, uiSpans, initSentry }) => { initSentry({ tracing: { trackComponents: ['ChildComponent'] }, sdk: { traceLifecycle: 'stream' } });