diff --git a/packages/rum-vue/src/domain/performance/addDurationVital.ts b/packages/rum-vue/src/domain/performance/addDurationVital.ts new file mode 100644 index 0000000000..b385a23711 --- /dev/null +++ b/packages/rum-vue/src/domain/performance/addDurationVital.ts @@ -0,0 +1,8 @@ +import type { RumPublicApi } from '@datadog/browser-rum-core' +import { onRumInit } from '../vuePlugin' + +export const addDurationVital: RumPublicApi['addDurationVital'] = (name, options) => { + onRumInit((_, rumPublicApi) => { + rumPublicApi.addDurationVital(name, options) + }) +} diff --git a/packages/rum-vue/src/domain/performance/useVueComponentTracker.spec.ts b/packages/rum-vue/src/domain/performance/useVueComponentTracker.spec.ts new file mode 100644 index 0000000000..5382b8a1f8 --- /dev/null +++ b/packages/rum-vue/src/domain/performance/useVueComponentTracker.spec.ts @@ -0,0 +1,77 @@ +import { createApp, defineComponent, h, nextTick, ref } from 'vue' +import { initializeVuePlugin } from '../../../test/initializeVuePlugin' +import { mockClock, registerCleanupTask } from '../../../../core/test' +// eslint-disable-next-line camelcase +import { UNSTABLE_useVueComponentTracker } from './useVueComponentTracker' + +const MOUNT_DURATION = 50 + +// Avoid @vue/test-utils to prevent Object.fromEntries compatibility issues on older browsers (Chrome 63) +function mountTrackedComponent(renderFn?: () => ReturnType) { + const container = document.createElement('div') + document.body.appendChild(container) + + const count = ref(0) + + const TrackedComponent = defineComponent({ + setup() { + UNSTABLE_useVueComponentTracker('MyComponent') + return { count } + }, + render() { + if (renderFn) { + return renderFn() + } + return h('div', this.count) + }, + }) + + const app = createApp(TrackedComponent) + app.mount(container) + + registerCleanupTask(() => { + app.unmount() + container.remove() + }) + + return { count, app } +} + +describe('UNSTABLE_useVueComponentTracker', () => { + it('reports a vueComponentRender vital on mount', () => { + const addDurationVitalSpy = jasmine.createSpy() + const clock = mockClock() + initializeVuePlugin({ publicApi: { addDurationVital: addDurationVitalSpy } }) + + mountTrackedComponent(() => { + clock.tick(MOUNT_DURATION) + return h('div') + }) + + expect(addDurationVitalSpy).toHaveBeenCalledTimes(1) + const [name, options] = addDurationVitalSpy.calls.mostRecent().args + expect(name).toBe('vueComponentRender') + expect(options).toEqual({ + description: 'MyComponent', + startTime: clock.timeStamp(0), + duration: MOUNT_DURATION, + context: { + is_first_render: true, + framework: 'vue', + }, + }) + }) + + it('reports is_first_render: false on update', async () => { + const addDurationVitalSpy = jasmine.createSpy() + initializeVuePlugin({ publicApi: { addDurationVital: addDurationVitalSpy } }) + + const { count } = mountTrackedComponent() + count.value++ + await nextTick() + + expect(addDurationVitalSpy).toHaveBeenCalledTimes(2) + const options = addDurationVitalSpy.calls.mostRecent().args[1] + expect(options.context.is_first_render).toBe(false) + }) +}) diff --git a/packages/rum-vue/src/domain/performance/useVueComponentTracker.ts b/packages/rum-vue/src/domain/performance/useVueComponentTracker.ts new file mode 100644 index 0000000000..694e596e85 --- /dev/null +++ b/packages/rum-vue/src/domain/performance/useVueComponentTracker.ts @@ -0,0 +1,62 @@ +import { onBeforeMount, onMounted, onBeforeUpdate, onUpdated } from 'vue' +import { clocksNow } from '@datadog/browser-core' +import { addDurationVital } from './addDurationVital' + +/** + * Track the performance of a Vue component. + * + * @category Performance + * @experimental + * @example + * ```ts + * import { UNSTABLE_useVueComponentTracker } from '@datadog/browser-rum-vue' + * + * // Inside a component's setup(): + * UNSTABLE_useVueComponentTracker('MyComponent') + * ``` + */ +// eslint-disable-next-line camelcase +export function UNSTABLE_useVueComponentTracker(name: string): void { + let mountStartClocks: ReturnType | undefined + let updateStartClocks: ReturnType | undefined + + onBeforeMount(() => { + mountStartClocks = clocksNow() + }) + + onMounted(() => { + if (!mountStartClocks) { + return + } + const duration = clocksNow().relative - mountStartClocks.relative + addDurationVital('vueComponentRender', { + description: name, + startTime: mountStartClocks.timeStamp, + duration, + context: { + is_first_render: true, + framework: 'vue', + }, + }) + }) + + onBeforeUpdate(() => { + updateStartClocks = clocksNow() + }) + + onUpdated(() => { + if (!updateStartClocks) { + return + } + const duration = clocksNow().relative - updateStartClocks.relative + addDurationVital('vueComponentRender', { + description: name, + startTime: updateStartClocks.timeStamp, + duration, + context: { + is_first_render: false, + framework: 'vue', + }, + }) + }) +} diff --git a/packages/rum-vue/src/entries/main.ts b/packages/rum-vue/src/entries/main.ts index 0e20336542..42d42cfdd3 100644 --- a/packages/rum-vue/src/entries/main.ts +++ b/packages/rum-vue/src/entries/main.ts @@ -1,3 +1,5 @@ export type { VuePluginConfiguration, VuePlugin } from '../domain/vuePlugin' export { vuePlugin } from '../domain/vuePlugin' export { addVueError } from '../domain/error/addVueError' +// eslint-disable-next-line camelcase +export { UNSTABLE_useVueComponentTracker } from '../domain/performance/useVueComponentTracker' diff --git a/test/apps/vue-router-app/src/main.ts b/test/apps/vue-router-app/src/main.ts index bd0aa65cd7..3d8cf4261b 100644 --- a/test/apps/vue-router-app/src/main.ts +++ b/test/apps/vue-router-app/src/main.ts @@ -19,6 +19,7 @@ const router = createRouter({ { path: '/user/:id', component: () => import('./pages/UserPage.vue') }, { path: '/guides/:catchAll(.*)*', component: () => import('./pages/GuidesPage.vue') }, { path: '/error-test', component: () => import('./pages/ErrorPage.vue') }, + { path: '/tracked', component: () => import('./pages/TrackedPage.vue') }, ], }) diff --git a/test/apps/vue-router-app/src/pages/HomePage.vue b/test/apps/vue-router-app/src/pages/HomePage.vue index 6fae5a7ea1..96c44a5932 100644 --- a/test/apps/vue-router-app/src/pages/HomePage.vue +++ b/test/apps/vue-router-app/src/pages/HomePage.vue @@ -3,6 +3,7 @@

Home

Go to User 42
Go to Guides 123
- Go to Error Test + Go to Error Test
+ Go to Tracked diff --git a/test/apps/vue-router-app/src/pages/TrackedPage.vue b/test/apps/vue-router-app/src/pages/TrackedPage.vue new file mode 100644 index 0000000000..21ba1a8965 --- /dev/null +++ b/test/apps/vue-router-app/src/pages/TrackedPage.vue @@ -0,0 +1,9 @@ + + diff --git a/test/e2e/scenario/plugins/vuePlugin.scenario.ts b/test/e2e/scenario/plugins/vuePlugin.scenario.ts index b2ce0aec7e..6a497b20bf 100644 --- a/test/e2e/scenario/plugins/vuePlugin.scenario.ts +++ b/test/e2e/scenario/plugins/vuePlugin.scenario.ts @@ -50,4 +50,16 @@ test.describe('plugin: vue', () => { expect(errorEvent.error.stack).toBeDefined() expect(errorEvent.context?.framework).toBe('vue') }) + + createTest('should send a vue component render vital event') + .withRum() + .withVueApp() + .run(async ({ page, flushEvents, intakeRegistry }) => { + await page.click('text=Go to Tracked') + await flushEvents() + + const vitalEvent = intakeRegistry.rumVitalEvents[0] + expect(vitalEvent.vital.description).toBe('TrackedPage') + expect(vitalEvent.vital.duration).toEqual(expect.any(Number)) + }) })