Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { Component } from 'vue';
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.
const DelayedChild = defineAsyncComponent(
() =>
new Promise<Component>(resolve => {
setTimeout(
() => resolve({ render: () => h('p', { id: 'delayed-child' }, 'Delayed child') }),
ASYNC_CHILD_DELAY_MS,
);
}),
);
</script>

<template>
<main>
<h1>Delayed</h1>
<DelayedChild />
</main>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <Root>'],
expectedUiSpanDescriptions: ['Application Render', 'Vue <Root>'].sort(),
},
{
route: '/components',
routeDescription: 'a route with an async component',
expectedUiSpanDescriptions: [
'Application Render',
'Vue <ComponentMainView>',
'Vue <ComponentOneView>',
'Vue <Root>',
],
expectedUiSpanDescriptions: OPTIONS_API_DISABLED
? ['Application Render', 'Vue <Root>'].sort()
: ['Application Render', 'Vue <ComponentMainView>', 'Vue <ComponentOneView>', 'Vue <Root>'].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' &&
Expand Down Expand Up @@ -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 <Root>']);

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';
Expand Down
8 changes: 7 additions & 1 deletion packages/nuxt/src/runtime/plugins/sentry.client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 `<Suspense>` 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

page:finish reopens root render span

Medium Severity

page:finish runs on every client navigation, and INTERNAL_extendVueRootRenderSpan only no-ops after $_sentryRootComponentSpan is cleared. A navigation inside the debounce window still sees that span, resets the timer, and moves Application Render's end timestamp onto the next page. Fast clicks and navigateTo redirects inflate the pageload child and can end it after the pageload parent.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7b3c0ee. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reproducible, but hardened anyway (and added a test).

A navigation ends the pageload idle span before the next page's page:finish
fires.

What was true: the guard only checked that the span reference exists, so the first post-navigation page:finish armed one dead timer. It now checks span.isRecording() and drops stale references, with a regression test.

}
});

Expand Down
3 changes: 3 additions & 0 deletions packages/vue/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions packages/vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
37 changes: 26 additions & 11 deletions packages/vue/src/integration.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand All @@ -13,7 +14,7 @@ const DEFAULT_CONFIG: VueOptions = {
attachErrorHandler: true,
tracingOptions: {
hooks: DEFAULT_HOOKS,
timeout: 2000,
timeout: DEFAULT_ROOT_SPAN_TIMEOUT,
trackComponents: false,
},
};
Expand Down Expand Up @@ -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<TracingOptions> | undefined): void {
const trackComponents = tracingOptions?.trackComponents;
const losesComponentSpans =
trackComponents === true || (Array.isArray(trackComponents) && trackComponents.length > 0);

if (!mixins || mixins.includes(mixin)) {
if (!losesComponentSpans) {
return;
}

Expand All @@ -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}`,
);
});
}
82 changes: 82 additions & 0 deletions packages/vue/src/rootInstrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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<Vue, RootInstrumentation>();

/**
* 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<string, unknown> } = { $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<Record<'beforeMount' | 'mounted', (this: VueSentry) => 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 `<Suspense>` 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);
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;
}
}
10 changes: 5 additions & 5 deletions packages/vue/src/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,9 +18,9 @@ const VUE_OPERATION_TO_SPAN_OP: Record<Operation, string> = {
destroy: UI_UNMOUNT,
};

type Mixins = Parameters<Vue['mixin']>[0];
export type Mixins = Parameters<Vue['mixin']>[0];

interface VueSentry extends ViewModel {
export interface VueSentry extends ViewModel {
readonly $root: VueSentry;
$_sentryComponentSpans?: {
[key: string]: Span | undefined;
Expand All @@ -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);
}
Expand Down Expand Up @@ -73,7 +73,7 @@ export const createTracingMixins = (options: Partial<TracingOptions> = {}): 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.
Expand Down
Loading
Loading