Skip to content

Commit 17921ba

Browse files
s1gr1dclaude
andauthored
fix(v10/vue): Share one root render span debounce timer across components (#24887)
First merge this: #24885 Backport of #24868 Closes #24858 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 034dbd5 commit 17921ba

6 files changed

Lines changed: 177 additions & 20 deletions

File tree

‎dev-packages/e2e-tests/test-applications/vue-3/src/views/DelayedView.vue‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { defineAsyncComponent, h } from 'vue';
55
// Must stay in sync with `ASYNC_CHILD_DELAY_S` in `tests/performance.test.ts`.
66
const ASYNC_CHILD_DELAY_MS = 300;
77
8-
// A child that mounts a fixed delay after the rest of the page, so tests can prove the
9-
// `Application Render` span does not wait for late children on either instrumentation path.
8+
// A child that mounts a fixed delay after the rest of the page, so tests can prove how each
9+
// instrumentation path treats late children:
10+
// - mixin: extends the `Application Render` span until the child mounts
11+
// - `app.mount()` wrap: does not observe late children
1012
const DelayedChild = defineAsyncComponent(
1113
() =>
1214
new Promise<Component>(resolve => {

‎dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,13 @@ test('sends a pageload transaction with a route name as transaction name if avai
190190
});
191191
});
192192

193-
// True on both variants: the mixin arms one debounce timer per component (`tracing.ts`), so a
194-
// late child never clears the root's earlier timer and the span ends at the root's mount. The
195-
// `app.mount()` wrap only observes the root, so it matches.
196-
test('ends the application render span before a delayed async component mounts', async ({ page }) => {
193+
// Mixin: records render activity from every component, so the late child pushes the root render span's end out to its own mount.
194+
// `app.mount()` wrap: only observes the root, so without the Options API the span still ends before the child mounts.
195+
const [minRenderSpanDuration, maxRenderSpanDuration] = OPTIONS_API_DISABLED
196+
? [0, ASYNC_CHILD_DELAY_S]
197+
: [ASYNC_CHILD_DELAY_S, 10];
198+
199+
test('ends the application render span at the last observed render activity', async ({ page }) => {
197200
const transactionPromise = waitForTransaction('vue-3', async transactionEvent => {
198201
return (
199202
transactionEvent.contexts?.trace?.op === 'pageload' &&
@@ -216,7 +219,8 @@ test('ends the application render span before a delayed async component mounts',
216219
expect(applicationRenderSpan?.timestamp).toEqual(expect.any(Number));
217220

218221
const duration = (applicationRenderSpan?.timestamp ?? 0) - (applicationRenderSpan?.start_timestamp ?? 0);
219-
expect(duration).toBeLessThan(ASYNC_CHILD_DELAY_S);
222+
expect(duration).toBeGreaterThanOrEqual(minRenderSpanDuration);
223+
expect(duration).toBeLessThan(maxRenderSpanDuration);
220224
});
221225

222226
test('sends a lifecycle span for each tracked components', async ({ page }) => {

‎packages/vue/src/rootInstrumentation.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ function createRootViewModel(): VueSentry {
1818
* builds where the Options API is compiled out and `app.mixin()` is a silent no-op (Nuxt 5 default).
1919
*
2020
* Vue runs all `mounted` hooks before `mount()` returns, so the wrap covers the same window as the
21-
* mixin's root hooks. Late mounts extend neither path; the mixin's debounce timers are per component.
21+
* mixin's root hooks. Late mounts extend the span only when mixins are used: they record render activity
22+
* from every component, while the `app.mount()` wrap only observes the root component.
2223
*/
2324
export function instrumentAppMountWithoutMixin(app: Vue, mixins: Mixins): void {
2425
// A second wrap would duplicate the root spans (e.g. user and Nuxt SDK both add the integration).

‎packages/vue/src/tracing.ts‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface VueSentry extends ViewModel {
1717
};
1818
$_sentryRootComponentSpan?: Span;
1919
$_sentryRootComponentSpanTimer?: ReturnType<typeof setTimeout>;
20+
$_sentryRootComponentSpanActivity?: number;
2021
}
2122

2223
// Mappings from operation to corresponding lifecycle hook.
@@ -31,18 +32,41 @@ const HOOKS: { [key in Operation]: Hook[] } = {
3132
update: ['beforeUpdate', 'updated'],
3233
};
3334

34-
/** End the top-level component span and activity with a debounce configured using `timeout` option */
35+
/**
36+
* End the root component span once no render activity happened for `timeout` (a debounce).
37+
*
38+
* All debounce state lives on `$root`, so hooks from every component share one timer. Each component
39+
* only writes a timestamp (no new timer is created). So mounting any number of components uses a single timer.
40+
*/
3541
function maybeEndRootComponentSpan(vm: VueSentry, timestamp: number, timeout: number): void {
36-
if (vm.$_sentryRootComponentSpanTimer) {
37-
clearTimeout(vm.$_sentryRootComponentSpanTimer);
42+
const root = vm.$root;
43+
root.$_sentryRootComponentSpanActivity = timestamp;
44+
45+
if (!root.$_sentryRootComponentSpanTimer) {
46+
scheduleRootComponentSpanEnd(root, timestamp, timeout);
3847
}
48+
}
49+
50+
/**
51+
* Fires `delayMs` after the activity that scheduled it. Activity recorded in the meantime pushes
52+
* the deadline out by the recorded gap, so the span always ends at the last activity timestamp.
53+
*/
54+
function scheduleRootComponentSpanEnd(root: VueSentry, activityWhenScheduled: number, delayMs: number): void {
55+
root.$_sentryRootComponentSpanTimer = setTimeout(() => {
56+
root.$_sentryRootComponentSpanTimer = undefined;
57+
58+
const lastActivity = root.$_sentryRootComponentSpanActivity ?? activityWhenScheduled;
59+
if (lastActivity > activityWhenScheduled) {
60+
// activity happened after this timer was scheduled: push the deadline out
61+
scheduleRootComponentSpanEnd(root, lastActivity, (lastActivity - activityWhenScheduled) * 1000);
62+
return;
63+
}
3964

40-
vm.$_sentryRootComponentSpanTimer = setTimeout(() => {
41-
if (vm.$root?.$_sentryRootComponentSpan) {
42-
vm.$root.$_sentryRootComponentSpan.end(timestamp);
43-
vm.$root.$_sentryRootComponentSpan = undefined;
65+
if (root.$_sentryRootComponentSpan) {
66+
root.$_sentryRootComponentSpan.end(lastActivity);
67+
root.$_sentryRootComponentSpan = undefined;
4468
}
45-
}, timeout);
69+
}, delayMs);
4670
}
4771

4872
/** Find if the current component exists in the provided `TracingOptions.trackComponents` array option. */

‎packages/vue/test/integration/mixinRegistration.test.ts‎

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment jsdom
33
*/
44

5+
import type * as SentryCore from '@sentry/core';
56
import { spanToJSON } from '@sentry/core';
67
import type { MockInstance } from 'vitest';
78
import { afterEach, beforeEach, describe, expect, it as baseIt, vi } from 'vitest';
@@ -10,6 +11,13 @@ import { createApp, defineAsyncComponent, h, nextTick, ref } from 'vue';
1011
import * as Sentry from '../../src';
1112
import type { Options, TracingOptions } from '../../src/types';
1213

14+
vi.mock('@sentry/core', async importOriginal => {
15+
return {
16+
...(await importOriginal<typeof SentryCore>()),
17+
timestampInSeconds: () => Date.now() / 1000,
18+
};
19+
});
20+
1321
const PUBLIC_DSN = 'https://username@domain/123';
1422
const ROOT_SPAN_TIMEOUT_MS = 100;
1523

@@ -256,9 +264,8 @@ describe('tracing mixin span creation', () => {
256264
]);
257265
});
258266

259-
// `maybeEndRootComponentSpan` arms one debounce timer per component, so a late child never
260-
// clears the root's earlier timer, and the root's timer ends the span first. The twin test in
261-
// the disabled describe below proves the `app.mount()` wrap matches.
267+
// The debounce only waits for recorded render activity, so a child that never mounts records
268+
// none and cannot keep the root render span open.
262269
it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => {
263270
const { app } = createAppWithDeferredChild();
264271
initSentry({ sdk: { app } });
@@ -271,6 +278,67 @@ describe('tracing mixin span creation', () => {
271278
]);
272279
});
273280

281+
it('extends the root render span while children keep mounting within the timeout', async ({
282+
uiSpans,
283+
initSentry,
284+
}) => {
285+
const showChild = ref(false);
286+
const child = { name: 'ChildComponent', render: () => h('p', 'child') };
287+
const app = createApp({ name: 'RootComponent', render: () => h('div', [showChild.value ? h(child) : null]) });
288+
initSentry({ sdk: { app } });
289+
const container = document.createElement('div');
290+
291+
await Sentry.startSpan({ name: 'pageload' }, async () => {
292+
app.mount(container);
293+
294+
vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS - 1);
295+
showChild.value = true;
296+
await nextTick();
297+
298+
// The original deadline passes, but the child's mount pushed it out.
299+
vi.advanceTimersByTime(2);
300+
expect(uiSpans.map(span => span.op)).not.toContain(UI_RENDER_SPAN_OP);
301+
302+
vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1);
303+
});
304+
305+
expect(uiSpans).toEqual([
306+
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP },
307+
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
308+
]);
309+
});
310+
311+
it('records UI spans when span streaming is enabled', ({ app, uiSpans, initSentry }) => {
312+
initSentry({ tracing: { trackComponents: ['ChildComponent'] }, sdk: { traceLifecycle: 'stream' } });
313+
314+
mountUnderActiveSpan(app);
315+
316+
// v10 does not have low-cardinality `ui` span names (only in v11)
317+
expect(uiSpans).toEqual([
318+
{ name: 'Vue <ChildComponent>', op: UI_MOUNT_SPAN_OP },
319+
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP },
320+
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
321+
]);
322+
});
323+
324+
it('records UI spans for anonymous components when span streaming is enabled', ({ uiSpans, initSentry }) => {
325+
const anonymousChild = { render: () => h('p', 'child') };
326+
const app = createApp({ render: () => h('div', [h(anonymousChild)]) });
327+
initSentry({
328+
tracing: { trackComponents: true },
329+
sdk: { app, traceLifecycle: 'stream' },
330+
});
331+
332+
mountUnderActiveSpan(app);
333+
334+
// v10 does not have low-cardinality `ui` span names (only in v11)
335+
expect(uiSpans).toEqual([
336+
{ name: 'Vue <Anonymous>', op: UI_MOUNT_SPAN_OP },
337+
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP },
338+
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
339+
]);
340+
});
341+
274342
// Vue 3 compiles `app.mixin()` down to a no-op returning the app when the `__VUE_OPTIONS_API__`
275343
// build flag is `false`. Nuxt 5 sets that flag by default (nuxt/nuxt#35791), so this stub matches
276344
// what those users run. The real build is covered by the `vue-3 (no Options API)` e2e variant.
@@ -311,7 +379,8 @@ describe('tracing mixin span creation', () => {
311379
expect(rootInstance.$el).toBe(container.firstElementChild);
312380
});
313381

314-
// Matches the mixin-path twin above: the mixin never waited for late children either.
382+
// Unlike the mixin path, the `app.mount()` wrap only observes the root, so a late child can
383+
// never extend the root render span here.
315384
it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => {
316385
const { app } = createAppWithDeferredChild();
317386
disableOptionsApi(app);

‎packages/vue/test/tracing/tracingMixin.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import { getActiveSpan, startInactiveSpan } from '@sentry/browser';
2+
import type * as SentryCore from '@sentry/core';
23
import type { Mock } from 'vitest';
34
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
45
import { DEFAULT_HOOKS } from '../../src/constants';
56
import { createTracingMixins } from '../../src/tracing';
67

8+
const clock = vi.hoisted(() => ({ now: 0 }));
9+
vi.mock('@sentry/core', async importOriginal => {
10+
return {
11+
...(await importOriginal<typeof SentryCore>()),
12+
timestampInSeconds: () => clock.now,
13+
};
14+
});
15+
716
vi.mock('@sentry/browser', () => {
817
return {
918
getActiveSpan: vi.fn(),
@@ -41,6 +50,7 @@ describe('Vue Tracing Mixins', () => {
4150

4251
beforeEach(() => {
4352
vi.clearAllMocks();
53+
clock.now = 0;
4454

4555
mockRootInstance = {
4656
$root: null,
@@ -147,6 +157,53 @@ describe('Vue Tracing Mixins', () => {
147157
);
148158
});
149159

160+
describe('Root Span Debounce', () => {
161+
it('arms a single shared timer on the root for any number of untracked components', () => {
162+
const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 });
163+
164+
mixins.beforeMount.call(mockRootInstance);
165+
for (let i = 0; i < 100; i++) {
166+
const child = { $root: mockRootInstance, componentName: `ChildComponent${i}` };
167+
mixins.beforeMount.call(child);
168+
mixins.mounted.call(child);
169+
}
170+
171+
expect(vi.getTimerCount()).toBe(1);
172+
});
173+
174+
it('ends the root span at the last recorded activity, pushing the deadline out for late activity', () => {
175+
const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 });
176+
clock.now = 10;
177+
mixins.beforeMount.call(mockRootInstance);
178+
const rootSpan = mockRootInstance.$_sentryRootComponentSpan;
179+
180+
clock.now = 10.8;
181+
mixins.mounted.call(mockVueInstance);
182+
183+
vi.advanceTimersByTime(1000);
184+
expect(rootSpan.end).not.toHaveBeenCalled();
185+
186+
vi.advanceTimersByTime(800);
187+
expect(rootSpan.end).toHaveBeenCalledWith(10.8);
188+
});
189+
190+
it('records no span when a component mounts after the root span already ended', () => {
191+
const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 });
192+
mixins.beforeMount.call(mockRootInstance);
193+
const rootSpan = mockRootInstance.$_sentryRootComponentSpan;
194+
vi.advanceTimersByTime(1001);
195+
expect(rootSpan.end).toHaveBeenCalledTimes(1);
196+
197+
clock.now = 5;
198+
mixins.beforeMount.call(mockVueInstance);
199+
mixins.mounted.call(mockVueInstance);
200+
vi.advanceTimersByTime(1001);
201+
202+
expect(rootSpan.end).toHaveBeenCalledTimes(1);
203+
expect(mockRootInstance.$_sentryRootComponentSpan).toBeUndefined();
204+
});
205+
});
206+
150207
describe('Component Span Lifecycle', () => {
151208
it('should create and end spans correctly through lifecycle hooks', () => {
152209
const mixins = createTracingMixins({ trackComponents: true });

0 commit comments

Comments
 (0)