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 { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-in
// 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 span with a parameterized URL', async ({ page }) => {
const pageloadSpanPromise = waitForStreamedSpan('vue-3', span => {
return span.is_segment && getSpanOp(span) === 'pageload';
Expand Down Expand Up @@ -106,27 +109,25 @@ test('sends a pageload span with a route name as span name if available', async
});
});

// 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.
expectedUiSpanNames: ['Application Render', 'Vue <Root>'],
expectedUiSpanNames: ['Application Render', 'Vue <Root>'].sort(),
},
{
route: '/components',
routeDescription: 'a route with an async component',
expectedUiSpanNames: ['Application Render', 'Vue <ComponentMainView>', 'Vue <ComponentOneView>', 'Vue <Root>'],
expectedUiSpanNames: OPTIONS_API_DISABLED
? ['Application Render', 'Vue <Root>'].sort()
: ['Application Render', 'Vue <ComponentMainView>', 'Vue <ComponentOneView>', 'Vue <Root>'].sort(),
},
].forEach(({ route, routeDescription, expectedUiSpanNames }) => {
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 spansPromise = collectStreamedSpans('vue-3', spans => {
return (
spans.some(
Expand Down Expand Up @@ -163,9 +164,38 @@ test('sends a pageload span with a route name as span name if available', async
});
});

// 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 spansPromise = collectStreamedSpans('vue-3', spans =>
spans.some(
span => span.is_segment && getSpanOp(span) === 'pageload' && span.attributes['url.path']?.value === '/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 spans = await spansPromise;
const uiSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ui.vue');

// Neither `DelayedView` nor its child is in `trackComponents`, so both variants expect the same set.
expect(uiSpans.map(span => span.name).sort()).toEqual(['Application Render', 'Vue <Root>']);

const applicationRenderSpan = uiSpans.find(span => span.name === 'Application Render');
expect(applicationRenderSpan?.start_timestamp).toEqual(expect.any(Number));
expect(applicationRenderSpan?.end_timestamp).toEqual(expect.any(Number));

const duration = (applicationRenderSpan?.end_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 expectedUiSpanNames = ['Application Render', 'Vue <ComponentMainView>', 'Vue <ComponentOneView>', 'Vue <Root>'];

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;
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);
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}`,
);
});
}
51 changes: 51 additions & 0 deletions packages/vue/src/rootInstrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Mixins, VueSentry } from './tracing';
import type { Vue } from './types';

const instrumentedApps = new WeakSet<Vue>();

/**
* 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): 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<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);
}
};
}
8 changes: 4 additions & 4 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 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