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 @@
+
+
+
+
+ Delayed
+
+
+
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('');
});
+ // 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(''));
+ 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);