From 7b3c0eeea0da16531fd916779fad22dc43627ca6 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:43:00 +0200 Subject: [PATCH 1/2] feat(vue,nuxt): Record default UI spans without Options API (mixins) --- .../nuxt-5/tests/tracing.client.test.ts | 11 +- .../vue-3/src/router/index.ts | 6 + .../vue-3/src/views/DelayedView.vue | 26 +++ .../vue-3/tests/performance.test.ts | 58 ++++-- .../nuxt/src/runtime/plugins/sentry.client.ts | 8 +- packages/vue/src/constants.ts | 3 + packages/vue/src/index.ts | 1 + packages/vue/src/integration.ts | 37 ++-- packages/vue/src/rootInstrumentation.ts | 74 +++++++ packages/vue/src/tracing.ts | 10 +- .../integration/mixinRegistration.test.ts | 182 ++++++++++++++++-- 11 files changed, 359 insertions(+), 57 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue create mode 100644 packages/vue/src/rootInstrumentation.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 5935f1ef1c3c..f8af579792eb 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 @@ -46,10 +46,9 @@ 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), 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'); + // 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 spansPromise = collectStreamedSpansUntilSegment( 'nuxt-5', @@ -77,10 +76,6 @@ 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 = collectStreamedSpansUntilSegment( 'nuxt-5', span => span.name === '/client-error' && getSpanOp(span) === 'pageload', 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 684cf1e778f5..350c0001bdcc 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 @@ -4,6 +4,9 @@ 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'; @@ -138,32 +141,25 @@ 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. +// 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. - expectedUiSpanDescriptions: ['Application Render', 'Vue '], + expectedUiSpanDescriptions: ['Application Render', 'Vue '].sort(), }, { route: '/components', routeDescription: 'a route with an async component', - expectedUiSpanDescriptions: [ - 'Application Render', - 'Vue ', - 'Vue ', - 'Vue ', - ], + expectedUiSpanDescriptions: OPTIONS_API_DISABLED + ? ['Application Render', 'Vue '].sort() + : ['Application Render', 'Vue ', 'Vue ', 'Vue '].sort(), }, ].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' && @@ -200,9 +196,39 @@ 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 }) => { + 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 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'); + // 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/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index 6d03b3cd9625..4935dd5072a8 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -1,5 +1,5 @@ import { getClient, GLOBAL_OBJ } from '@sentry/core'; -import { browserTracingIntegration, vueIntegration } from '@sentry/vue'; +import { browserTracingIntegration, INTERNAL_extendVueRootRenderSpan, vueIntegration } from '@sentry/vue'; import { defineNuxtPlugin, isNuxtError } from 'nuxt/app'; import type { GlobalObjWithIntegrationOptions } from '../../client/vueIntegration'; import { reportNuxtError } from '../utils'; @@ -62,6 +62,12 @@ export default defineNuxtPlugin({ attachErrorHandler: false, }), ); + + // Without these hooks the root render span ends at the root component's mount, before the + // page's `` boundary resolves. Both hooks are no-ops on the mixin path (Options + // API enabled), which keeps its historical span boundaries. + nuxtApp.hook('app:suspense:resolve', () => INTERNAL_extendVueRootRenderSpan(vueApp)); + nuxtApp.hook('page:finish', () => INTERNAL_extendVueRootRenderSpan(vueApp)); } }); 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/index.ts b/packages/vue/src/index.ts index 3e870ff1062b..75cee45949fd 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -7,6 +7,7 @@ export { init } from './sdk'; export { browserTracingIntegration } from './browserTracingIntegration'; export { attachErrorHandler } from './errorhandler'; export { createTracingMixins } from './tracing'; +export { INTERNAL_extendVueRootRenderSpan } from './rootInstrumentation'; export { vueIntegration } from './integration'; export type { VueIntegrationOptions } from './integration'; export { createSentryPiniaPlugin } from './pinia'; diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index 856fdcf56d9b..362eb03b10a7 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, }, }; @@ -76,21 +77,35 @@ const vueInit = (app: Vue, options: Options): void => { if (hasSpansEnabled(options)) { const mixins = createTracingMixins(options.tracingOptions); app.mixin(mixins); - warnIfMixinWasDropped(app, mixins); + if (!mixinWasApplied(app, mixins)) { + instrumentAppMountWithoutMixin(app, mixins, options.tracingOptions?.timeout || DEFAULT_ROOT_SPAN_TIMEOUT); + warnAboutLostComponentTracking(app, options.tracingOptions); + } } }; /** - * `app.mixin()` is a no-op when Options API is disabled (default in Nuxt 5). - * Without mixins (Options API) users lose every UI span (render, mount, etc.) - + * 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 warnIfMixinWasDropped(app: Vue, mixin: unknown): void { - // Vue 2 has no `_context` and no Options API flag, so there is nothing to check. +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 (!mixins || mixins.includes(mixin)) { + if (!losesComponentSpans) { return; } @@ -103,7 +118,7 @@ function warnIfMixinWasDropped(app: Vue, mixin: unknown): void { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( - `[@sentry/vue]: The Vue Options API is disabled (\`__VUE_OPTIONS_API__: false\`), so Sentry cannot record UI spans. You lose \`Application Render\` and the component mount, update and unmount spans. Errors, pageload spans and navigation spans still work. ${fix}`, + `[@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..ca8dd075f0c1 --- /dev/null +++ b/packages/vue/src/rootInstrumentation.ts @@ -0,0 +1,74 @@ +import { timestampInSeconds } from '@sentry/core'; +import type { Mixins, VueSentry } from './tracing'; +import { maybeEndRootComponentSpan } from './tracing'; +import type { Vue } from './types'; + +interface RootInstrumentation { + vm: VueSentry; + timeout: number; +} + +const instrumentedApps = new WeakMap(); + +/** + * 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, timeout: number): 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.set(app, { vm, timeout }); + + // `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); + } + }; +} + +/** + * Extends the debounce that ends the `Application Render` span, so framework SDKs can report + * render activity the root cannot see (e.g. Nuxt's `` resolving). No-op on the mixin + * path and after the span has ended. + * + * @internal Exported for the Sentry Nuxt SDK, not part of the stable public API. + * @experimental May change or be removed in any release. + */ +export function INTERNAL_extendVueRootRenderSpan(app: Vue): void { + const instrumentation = instrumentedApps.get(app); + // Skip after the span ended; otherwise each call (every Nuxt `page:finish`) arms a dead timer. + if (instrumentation?.vm.$_sentryRootComponentSpan) { + maybeEndRootComponentSpan(instrumentation.vm, timestampInSeconds(), instrumentation.timeout); + } +} diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index 71b610a2e0ee..7302846fb088 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -3,7 +3,7 @@ import type { Span } from '@sentry/core'; import { debug, timestampInSeconds, uniq } from '@sentry/core'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { UI_MOUNT, UI_RENDER, UI_UNMOUNT, UI_UPDATE } from '@sentry/conventions/op'; -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'; @@ -18,9 +18,9 @@ const VUE_OPERATION_TO_SPAN_OP: Record = { destroy: UI_UNMOUNT, }; -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; @@ -42,7 +42,7 @@ const HOOKS: { [key in Operation]: Hook[] } = { }; /** End the top-level component span and activity with a debounce configured using `timeout` option */ -function maybeEndRootComponentSpan(vm: VueSentry, timestamp: number, timeout: number): void { +export function maybeEndRootComponentSpan(vm: VueSentry, timestamp: number, timeout: number): void { if (vm.$_sentryRootComponentSpanTimer) { clearTimeout(vm.$_sentryRootComponentSpanTimer); } @@ -73,7 +73,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 index bef3c07891db..3079f653b8d5 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, Ref } from 'vue'; -import { createApp, h, nextTick, ref } from 'vue'; +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'; @@ -43,6 +43,19 @@ function createReactiveTestApp(): { app: App; message: Ref } { 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; @@ -183,8 +196,7 @@ 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. + // `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(); @@ -206,9 +218,8 @@ describe('tracing mixin span creation', () => { ]); }); - // `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. + // 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()`. The `create` operation maps to `ui.mount`. it('tracks the root component for create hooks without trackComponents', ({ app, uiSpans, initSentry }) => { initSentry({ tracing: { hooks: ['create'] } }); @@ -248,6 +259,21 @@ 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. + 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. @@ -256,9 +282,7 @@ describe('tracing mixin span creation', () => { app.mixin = () => app; } - // Drop `.fails` once tracing no longer depends on `app.mixin()`. Vitest then reports this as a - // failure, which is the signal to delete the modifier. - it.fails('creates the same UI spans as with the Options API enabled', ({ app, uiSpans, initSentry }) => { + it('creates the same UI spans as with the Options API enabled', ({ app, uiSpans, initSentry }) => { disableOptionsApi(app); initSentry(); @@ -279,6 +303,86 @@ describe('tracing mixin span creation', () => { 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. + // Framework SDKs can push the end out through `INTERNAL_extendVueRootRenderSpan` (the Nuxt SDK does). + 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('extendVueRootRenderSpan pushes back the root render span end', ({ app, uiSpans, initSentry }) => { + disableOptionsApi(app); + initSentry(); + const container = document.createElement('div'); + + Sentry.startSpan({ name: 'pageload' }, () => { + app.mount(container); + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS / 2); + Sentry.INTERNAL_extendVueRootRenderSpan(app); + // The original debounce deadline has passed by now; only the extension keeps the span open. + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS / 2 + 1); + expect(uiSpans).toEqual([{ name: 'Vue ', op: UI_MOUNT_SPAN_OP }]); + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS / 2); + }); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + // Guards against re-arming a timer on every call (e.g. each Nuxt `page:finish`, forever). + it('extendVueRootRenderSpan does nothing once the root render span has ended', ({ app, uiSpans, initSentry }) => { + disableOptionsApi(app); + initSentry(); + mountUnderActiveSpan(app); + + const timersBeforeExtend = vi.getTimerCount(); + Sentry.INTERNAL_extendVueRootRenderSpan(app); + + expect(vi.getTimerCount()).toBe(timersBeforeExtend); + 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); @@ -287,6 +391,21 @@ describe('tracing mixin span creation', () => { expect(app.config.errorHandler).toBeDefined(); }); }); + + // On the mixin path no fallback is registered, so there is nothing for the helper to extend. + it('extendVueRootRenderSpan is a no-op for an app instrumented through the mixin', ({ app, uiSpans, initSentry }) => { + initSentry(); + mountUnderActiveSpan(app); + + const timersBeforeExtend = vi.getTimerCount(); + Sentry.INTERNAL_extendVueRootRenderSpan(app); + + expect(vi.getTimerCount()).toBe(timersBeforeExtend); + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); }); describe('Options API detection guard', () => { @@ -302,18 +421,49 @@ describe('Options API detection guard', () => { consoleWarn.mockRestore(); }); - it('warns when the app dropped the tracing mixin', ({ app, initSentry }) => { + // 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(); + initSentry({ tracing: { trackComponents: true } }); expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining('`define` config of your bundler')); expect(consoleWarn).not.toHaveBeenCalledWith(expect.stringContaining('nuxt.config.ts')); @@ -324,13 +474,13 @@ describe('Options API detection guard', () => { app.mixin = () => app; Object.defineProperty(app, '$nuxt', { get: () => ({}) }); - initSentry(); + 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(); + initSentry({ tracing: { trackComponents: true } }); expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); }); @@ -338,7 +488,7 @@ describe('Options API detection guard', () => { it('does not warn when tracing is disabled, because no mixin is registered', ({ app, initSentry }) => { app.mixin = () => app; - initSentry({ sdk: { tracesSampleRate: undefined } }); + initSentry({ tracing: { trackComponents: true }, sdk: { tracesSampleRate: undefined } }); expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); }); @@ -347,7 +497,7 @@ describe('Options API detection guard', () => { it('does not warn for a Vue 2 constructor', ({ initSentry }) => { const vue2Constructor = { config: {}, mixin: () => {} }; - initSentry({ sdk: { app: undefined, Vue: vue2Constructor } }); + initSentry({ tracing: { trackComponents: true }, sdk: { app: undefined, Vue: vue2Constructor } }); expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); }); From b16a3759954820ab4553436e9d1a3e894996348f Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:40:33 +0200 Subject: [PATCH 2/2] review suggestion --- packages/vue/src/rootInstrumentation.ts | 12 +++++++-- .../integration/mixinRegistration.test.ts | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/vue/src/rootInstrumentation.ts b/packages/vue/src/rootInstrumentation.ts index ca8dd075f0c1..179cd56f5140 100644 --- a/packages/vue/src/rootInstrumentation.ts +++ b/packages/vue/src/rootInstrumentation.ts @@ -67,8 +67,16 @@ export function instrumentAppMountWithoutMixin(app: Vue, mixins: Mixins, timeout */ export function INTERNAL_extendVueRootRenderSpan(app: Vue): void { const instrumentation = instrumentedApps.get(app); - // Skip after the span ended; otherwise each call (every Nuxt `page:finish`) arms a dead timer. - if (instrumentation?.vm.$_sentryRootComponentSpan) { + const span = instrumentation?.vm.$_sentryRootComponentSpan; + // No span: mixin path, or the debounce already ended it. + if (!instrumentation || !span) { + return; + } + + if (span.isRecording()) { maybeEndRootComponentSpan(instrumentation.vm, timestampInSeconds(), instrumentation.timeout); + } else { + // Ended externally, e.g. by a navigation cancelling the pageload. Drop the stale reference. + instrumentation.vm.$_sentryRootComponentSpan = undefined; } } diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 3079f653b8d5..1446a37b3c46 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 { Span } from '@sentry/core'; import { spanToJSON } from '@sentry/core'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it as baseIt, vi } from 'vitest'; @@ -383,6 +384,30 @@ describe('tracing mixin span creation', () => { ]); }); + it('extendVueRootRenderSpan does not re-arm the debounce for an externally ended span', ({ app, initSentry }) => { + disableOptionsApi(app); + initSentry(); + let renderSpan: Span | undefined; + Sentry.getClient()?.on('spanStart', span => { + if (spanToJSON(span).name === 'Application Render') { + renderSpan = span; + } + }); + Sentry.startSpan({ name: 'pageload' }, () => { + app.mount(document.createElement('div')); + }); + expect(renderSpan).toBeDefined(); + // Simulates a navigation ending the pageload and its running children before the debounce fires. + renderSpan?.end(); + + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS / 2); + Sentry.INTERNAL_extendVueRootRenderSpan(app); + // Past the original debounce deadline, before any re-armed one could fire. + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS / 2 + 10); + + expect(vi.getTimerCount()).toBe(0); + }); + it('attaches the Vue error handler', ({ app, initSentry }) => { disableOptionsApi(app);