Skip to content
Merged
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 @@ -9,11 +9,8 @@ test.describe('Cache Instrumentation', () => {
const spans = await collectStreamedSpans('nuxt-4', spans =>
spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/cache-test'),
);
const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/cache-test');

return spans.filter(
span => span.trace_id === rootSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.cache.nuxt',
);
return spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.cache.nuxt');
}

test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,49 @@ test('sends a navigation root span with a parameterized URL', async ({ page }) =
});
});

test('sends an application render span and a root component span on pageload', async ({ page }) => {
const spansPromise = collectStreamedSpans('nuxt-4', spans =>
spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'),
);

await page.goto(`/client-error`);

const spans = await spansPromise;
const uiSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ui.vue');

const applicationRenderSpans = uiSpans.filter(span => span.name === 'Application Render');
expect(applicationRenderSpans).toHaveLength(1);
expect(applicationRenderSpans[0]).toMatchObject({
name: 'Application Render',
is_segment: false,
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
start_timestamp: expect.any(Number),
end_timestamp: expect.any(Number),
attributes: expect.objectContaining({
'sentry.op': { type: 'string', value: 'ui.render' },
'sentry.origin': { type: 'string', value: 'auto.ui.vue' },
}),
});

const rootComponentSpans = uiSpans.filter(span => span.name === 'Vue <Root>');
expect(rootComponentSpans).toHaveLength(1);
expect(rootComponentSpans[0]).toMatchObject({
name: 'Vue <Root>',
is_segment: false,
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
start_timestamp: expect.any(Number),
end_timestamp: expect.any(Number),
attributes: expect.objectContaining({
'sentry.op': { type: 'string', value: 'ui.mount' },
'sentry.origin': { type: 'string', value: 'auto.ui.vue' },
}),
});
});

test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => {
const spansPromise = collectStreamedSpans('nuxt-4', spans =>
spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,9 @@ test.describe('distributed tracing', () => {
}),
});

// All 3 root spans and the http.client span should share the same trace_id
// `collectStreamedSpans` already guarantees the pageload and http.client spans share a trace,
// so only the independently awaited server spans need the check.
expect(pageloadSpan?.trace_id).toBeDefined();
expect(pageloadSpan?.trace_id).toBe(httpClientSpan?.trace_id);
expect(pageloadSpan?.trace_id).toBe(ssrSpan.trace_id);
expect(pageloadSpan?.trace_id).toBe(serverReqSpan.trace_id);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
imports: { autoImport: false },

// FIXME: Remove once component tracking no longer relies on Vue's Options API.
// Nuxt 5 disables it by default: https://github.com/nuxt/nuxt/pull/35791
vue: { optionsApi: true },

routeRules: {
'/rendering-modes/client-side-only-page': { ssr: false },
'/rendering-modes/isr-cached-page': { isr: true },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils';

async function collectRequestSpans() {
const spans = await collectStreamedSpans('nuxt-5', spans =>
function collectRequestSpans() {
return collectStreamedSpans('nuxt-5', spans =>
spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test'),
);
const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/middleware-test');

return spans.filter(span => span.trace_id === rootSpan?.trace_id);
}

test.describe('Server Middleware Instrumentation', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ test('sends a navigation root span with a parameterized URL', async ({ page }) =
});
});

// fixme: note that this test only works because we explictly enabled Vue’s Options API in the nuxt config
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');

const spansPromise = collectStreamedSpans('nuxt-5', spans =>
spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'),
);
Expand All @@ -70,3 +74,50 @@ 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 = collectStreamedSpans('nuxt-5', spans =>
spans.some(span => span.name === '/client-error' && span.is_segment && getSpanOp(span) === 'pageload'),
);

await page.goto(`/client-error`);

const spans = await spansPromise;
const uiSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ui.vue');

const applicationRenderSpans = uiSpans.filter(span => span.name === 'Application Render');
expect(applicationRenderSpans).toHaveLength(1);
expect(applicationRenderSpans[0]).toMatchObject({
name: 'Application Render',
is_segment: false,
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
start_timestamp: expect.any(Number),
end_timestamp: expect.any(Number),
attributes: expect.objectContaining({
'sentry.op': { type: 'string', value: 'ui.render' },
'sentry.origin': { type: 'string', value: 'auto.ui.vue' },
}),
});

const rootComponentSpans = uiSpans.filter(span => span.name === 'Vue <Root>');
expect(rootComponentSpans).toHaveLength(1);
expect(rootComponentSpans[0]).toMatchObject({
name: 'Vue <Root>',
is_segment: false,
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
start_timestamp: expect.any(Number),
end_timestamp: expect.any(Number),
attributes: expect.objectContaining({
'sentry.op': { type: 'string', value: 'ui.mount' },
'sentry.origin': { type: 'string', value: 'auto.ui.vue' },
}),
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,68 @@ 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.
[
{
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>'],
},
{
route: '/components',
routeDescription: 'a route with an async component',
expectedUiSpanDescriptions: [
'Application Render',
'Vue <ComponentMainView>',
'Vue <ComponentOneView>',
'Vue <Root>',
],
},
].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' &&
transactionEvent.contexts?.trace?.data?.['url.path'] === route
);
});

await page.goto(route);

const rootSpan = await transactionPromise;
const uiSpans = (rootSpan.spans || []).filter(span => span.origin === 'auto.ui.vue');

expect(uiSpans.map(span => span.description).sort()).toEqual(expectedUiSpanDescriptions);

const applicationRenderSpan = uiSpans.find(span => span.description === 'Application Render');
expect(applicationRenderSpan).toMatchObject({
data: {
'sentry.op': 'ui.render',
'sentry.origin': 'auto.ui.vue',
},
op: 'ui.render',
origin: 'auto.ui.vue',
});

const rootComponentSpan = uiSpans.find(span => span.description === 'Vue <Root>');
expect(rootComponentSpan).toMatchObject({
data: {
'sentry.op': 'ui.mount',
'sentry.origin': 'auto.ui.vue',
},
op: 'ui.mount',
origin: 'auto.ui.vue',
});
});
});

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');
Expand Down
79 changes: 77 additions & 2 deletions packages/vue/test/integration/mixinRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from 'vue';
import { createApp, h } from 'vue';
import type { App, Ref } from 'vue';
import { createApp, h, nextTick, ref } from 'vue';
import * as Sentry from '../../src';
import type { Options, TracingOptions } from '../../src/types';

Expand All @@ -19,6 +19,7 @@ const SENTRY_ORIGIN_ATTRIBUTE = 'sentry.origin';
const VUE_SPAN_ORIGIN = 'auto.ui.vue';
const UI_MOUNT_SPAN_OP = 'ui.mount';
const UI_RENDER_SPAN_OP = 'ui.render';
const UI_UPDATE_SPAN_OP = 'ui.update';

interface UiSpan {
name: string;
Expand All @@ -34,6 +35,14 @@ function createTestApp(): App {
return createApp({ name: 'RootComponent', render: () => h('div', [h(child)]) });
}

/** Like `createTestApp`, but the root render reads a ref, so mutating it re-renders the root. */
function createReactiveTestApp(): { app: App; message: Ref<string> } {
const message = ref('initial message');
const child = { name: 'ChildComponent', render: () => h('p', 'child') };
const app = createApp({ name: 'RootComponent', render: () => h('div', [h('span', message.value), h(child)]) });
return { app, message };
}

/** 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;
Expand Down Expand Up @@ -173,6 +182,72 @@ 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.

it('tracks the root component for update hooks without trackComponents', async ({ uiSpans, initSentry }) => {
const { app, message } = createReactiveTestApp();
initSentry({ tracing: { hooks: ['update'] }, sdk: { app } });
const container = document.createElement('div');

await Sentry.startSpan({ name: 'pageload' }, async () => {
app.mount(container);
message.value = 'updated message';
// Works under fake timers: Vue flushes re-renders through microtasks, not timers.
await nextTick();
vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1);
});

expect(uiSpans).toEqual([
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP },
{ name: 'Vue <Root>', op: UI_UPDATE_SPAN_OP },
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
]);
});

// `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.
it('tracks the root component for create hooks without trackComponents', ({ app, uiSpans, initSentry }) => {
initSentry({ tracing: { hooks: ['create'] } });

mountUnderActiveSpan(app);

expect(uiSpans).toEqual([
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP }, // create
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP }, // mount (DEFAULT_HOOKS is always merged in)
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
]);
});

// `activate` maps to the `activated`/`deactivated` hooks, which Vue only calls inside
// `<KeepAlive>`. A root component is never kept alive, so `activate` produces no root span.
it('does not track the root component for activate hooks', ({ app, uiSpans, initSentry }) => {
initSentry({ tracing: { hooks: ['activate'] } });

mountUnderActiveSpan(app);

expect(uiSpans).toEqual([
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP },
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
]);
});

// Component spans are keyed by operation, not by span op, so `create` and `mount` each emit their
// own root span even though both map to `ui.mount`.
it('emits two root mount spans when both create and mount hooks are enabled', ({ app, uiSpans, initSentry }) => {
initSentry({ tracing: { hooks: ['create', 'mount'] } });

mountUnderActiveSpan(app);

expect(uiSpans).toEqual([
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP }, // create
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP }, // mount
{ 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.
Expand Down
Loading