From 8c710e032103a3e7ebea69f316afa5e5d693f687 Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Fri, 13 Mar 2026 11:49:44 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20vuePlugin=20and=20addVueError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tools/stackTrace/handlingStack.ts | 3 +- .../src/domain/error/addVueError.spec.ts | 45 ++++++++++++ .../rum-vue/src/domain/error/addVueError.ts | 41 +++++++++++ packages/rum-vue/src/domain/vuePlugin.spec.ts | 48 +++++++++++++ packages/rum-vue/src/domain/vuePlugin.ts | 68 +++++++++++++++++++ packages/rum-vue/src/entries/main.ts | 4 +- packages/rum-vue/test/initializeVuePlugin.ts | 26 +++++++ 7 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 packages/rum-vue/src/domain/error/addVueError.spec.ts create mode 100644 packages/rum-vue/src/domain/error/addVueError.ts create mode 100644 packages/rum-vue/src/domain/vuePlugin.spec.ts create mode 100644 packages/rum-vue/src/domain/vuePlugin.ts create mode 100644 packages/rum-vue/test/initializeVuePlugin.ts diff --git a/packages/core/src/tools/stackTrace/handlingStack.ts b/packages/core/src/tools/stackTrace/handlingStack.ts index d5cacd6d68..d649f9ab1c 100644 --- a/packages/core/src/tools/stackTrace/handlingStack.ts +++ b/packages/core/src/tools/stackTrace/handlingStack.ts @@ -16,9 +16,10 @@ export function createHandlingStack( | 'instrumented method' | 'log' | 'react error' + | 'nextjs error' + | 'vue error' | 'view' | 'vital' - | 'nextjs error' ): string { /** * Skip the two internal frames: diff --git a/packages/rum-vue/src/domain/error/addVueError.spec.ts b/packages/rum-vue/src/domain/error/addVueError.spec.ts new file mode 100644 index 0000000000..87e4caf7de --- /dev/null +++ b/packages/rum-vue/src/domain/error/addVueError.spec.ts @@ -0,0 +1,45 @@ +import { initializeVuePlugin } from '../../../test/initializeVuePlugin' +import { addVueError } from './addVueError' + +describe('addVueError', () => { + it('reports the error to the SDK', () => { + const addErrorSpy = jasmine.createSpy() + initializeVuePlugin({ addError: addErrorSpy }) + + const error = new Error('something broke') + addVueError(error, null, 'mounted hook') + + expect(addErrorSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ + error, + handlingStack: jasmine.any(String), + componentStack: 'mounted hook', + startClocks: jasmine.any(Object), + context: { framework: 'vue' }, + }) + ) + }) + + it('handles empty info gracefully', () => { + const addErrorSpy = jasmine.createSpy() + initializeVuePlugin({ addError: addErrorSpy }) + addVueError(new Error('oops'), null, '') + expect(addErrorSpy).toHaveBeenCalledTimes(1) + expect(addErrorSpy.calls.mostRecent().args[0].componentStack).toBeUndefined() + }) + + it('should merge dd_context from the original error with vue error context', () => { + const addErrorSpy = jasmine.createSpy() + initializeVuePlugin({ addError: addErrorSpy }) + const originalError = new Error('error message') + ;(originalError as any).dd_context = { component: 'Menu', param: 123 } + + addVueError(originalError, null, 'mounted hook') + + expect(addErrorSpy.calls.mostRecent().args[0].context).toEqual({ + framework: 'vue', + component: 'Menu', + param: 123, + }) + }) +}) diff --git a/packages/rum-vue/src/domain/error/addVueError.ts b/packages/rum-vue/src/domain/error/addVueError.ts new file mode 100644 index 0000000000..f86386f0f4 --- /dev/null +++ b/packages/rum-vue/src/domain/error/addVueError.ts @@ -0,0 +1,41 @@ +import type { ComponentPublicInstance } from 'vue' +import { callMonitored, clocksNow, createHandlingStack } from '@datadog/browser-core' +import { onVueStart } from '../vuePlugin' + +/** + * Add a Vue error to the RUM session. + * + * @category Error + * @example + * ```ts + * import { createApp } from 'vue' + * import { addVueError } from '@datadog/browser-rum-vue' + * + * const app = createApp(App) + * // Report all Vue errors to Datadog automatically + * app.config.errorHandler = addVueError + * ``` + */ +export function addVueError( + error: unknown, + // Required by Vue's app.config.errorHandler signature, but not used by the SDK + _instance: ComponentPublicInstance | null, + info: string +) { + const handlingStack = createHandlingStack('vue error') + const startClocks = clocksNow() + onVueStart((addError) => { + callMonitored(() => { + addError({ + error, + handlingStack, + componentStack: info || undefined, + startClocks, + context: { + ...(typeof error === 'object' && error !== null ? (error as { dd_context?: object }).dd_context : undefined), + framework: 'vue', + }, + }) + }) + }) +} diff --git a/packages/rum-vue/src/domain/vuePlugin.spec.ts b/packages/rum-vue/src/domain/vuePlugin.spec.ts new file mode 100644 index 0000000000..925c773e4d --- /dev/null +++ b/packages/rum-vue/src/domain/vuePlugin.spec.ts @@ -0,0 +1,48 @@ +import type { RumInitConfiguration, RumPublicApi } from '@datadog/browser-rum-core' +import { registerCleanupTask } from '../../../core/test' +import { onVueInit, vuePlugin, resetVuePlugin } from './vuePlugin' + +const PUBLIC_API = {} as RumPublicApi +const INIT_CONFIGURATION = {} as RumInitConfiguration + +describe('vuePlugin', () => { + beforeEach(() => { + registerCleanupTask(() => resetVuePlugin()) + }) + + it('returns a plugin object with name "vue"', () => { + expect(vuePlugin()).toEqual(jasmine.objectContaining({ name: 'vue' })) + }) + + it('calls callbacks registered with onVueInit during onInit', () => { + const spy = jasmine.createSpy() + const config = {} + onVueInit(spy) + vuePlugin(config).onInit({ publicApi: PUBLIC_API, initConfiguration: INIT_CONFIGURATION }) + expect(spy).toHaveBeenCalledOnceWith(config, PUBLIC_API) + }) + + it('calls callbacks immediately if onInit was already invoked', () => { + const spy = jasmine.createSpy() + const config = {} + vuePlugin(config).onInit({ publicApi: PUBLIC_API, initConfiguration: INIT_CONFIGURATION }) + onVueInit(spy) + expect(spy).toHaveBeenCalledOnceWith(config, PUBLIC_API) + }) + + it('sets trackViewsManually when router is true', () => { + const initConfiguration = { ...INIT_CONFIGURATION } + vuePlugin({ router: true }).onInit({ publicApi: PUBLIC_API, initConfiguration }) + expect(initConfiguration.trackViewsManually).toBe(true) + }) + + it('does not set trackViewsManually when router is false', () => { + const initConfiguration = { ...INIT_CONFIGURATION } + vuePlugin({ router: false }).onInit({ publicApi: PUBLIC_API, initConfiguration }) + expect(initConfiguration.trackViewsManually).toBeUndefined() + }) + + it('returns configuration telemetry', () => { + expect(vuePlugin({ router: true }).getConfigurationTelemetry()).toEqual({ router: true }) + }) +}) diff --git a/packages/rum-vue/src/domain/vuePlugin.ts b/packages/rum-vue/src/domain/vuePlugin.ts new file mode 100644 index 0000000000..124e121d49 --- /dev/null +++ b/packages/rum-vue/src/domain/vuePlugin.ts @@ -0,0 +1,68 @@ +import type { RumPlugin, RumPublicApi, StartRumResult } from '@datadog/browser-rum-core' + +let globalPublicApi: RumPublicApi | undefined +let globalConfiguration: VuePluginConfiguration | undefined +let globalAddError: StartRumResult['addError'] | undefined + +type InitSubscriber = (configuration: VuePluginConfiguration, rumPublicApi: RumPublicApi) => void +type StartSubscriber = (addError: StartRumResult['addError']) => void + +const onRumInitSubscribers: InitSubscriber[] = [] +const onRumStartSubscribers: StartSubscriber[] = [] + +export interface VuePluginConfiguration { + router?: boolean +} + +export type VuePlugin = Required + +export function vuePlugin(configuration: VuePluginConfiguration = {}): VuePlugin { + return { + name: 'vue', + onInit({ publicApi, initConfiguration }) { + globalPublicApi = publicApi + globalConfiguration = configuration + for (const subscriber of onRumInitSubscribers) { + subscriber(globalConfiguration, globalPublicApi) + } + if (configuration.router) { + initConfiguration.trackViewsManually = true + } + }, + onRumStart({ addError }) { + globalAddError = addError + if (addError) { + for (const subscriber of onRumStartSubscribers) { + subscriber(addError) + } + } + }, + getConfigurationTelemetry() { + return { router: !!configuration.router } + }, + } satisfies RumPlugin +} + +export function onVueInit(callback: InitSubscriber) { + if (globalConfiguration && globalPublicApi) { + callback(globalConfiguration, globalPublicApi) + } else { + onRumInitSubscribers.push(callback) + } +} + +export function onVueStart(callback: StartSubscriber) { + if (globalAddError) { + callback(globalAddError) + } else { + onRumStartSubscribers.push(callback) + } +} + +export function resetVuePlugin() { + globalPublicApi = undefined + globalConfiguration = undefined + globalAddError = undefined + onRumInitSubscribers.length = 0 + onRumStartSubscribers.length = 0 +} diff --git a/packages/rum-vue/src/entries/main.ts b/packages/rum-vue/src/entries/main.ts index 336ce12bb9..0e20336542 100644 --- a/packages/rum-vue/src/entries/main.ts +++ b/packages/rum-vue/src/entries/main.ts @@ -1 +1,3 @@ -export {} +export type { VuePluginConfiguration, VuePlugin } from '../domain/vuePlugin' +export { vuePlugin } from '../domain/vuePlugin' +export { addVueError } from '../domain/error/addVueError' diff --git a/packages/rum-vue/test/initializeVuePlugin.ts b/packages/rum-vue/test/initializeVuePlugin.ts new file mode 100644 index 0000000000..74aaafc609 --- /dev/null +++ b/packages/rum-vue/test/initializeVuePlugin.ts @@ -0,0 +1,26 @@ +import type { RumInitConfiguration, RumPublicApi, StartRumResult } from '@datadog/browser-rum-core' +import { noop } from '@datadog/browser-core' +import type { VuePluginConfiguration } from '../src/domain/vuePlugin' +import { vuePlugin, resetVuePlugin } from '../src/domain/vuePlugin' +import { registerCleanupTask } from '../../core/test' + +export function initializeVuePlugin({ + configuration = {}, + initConfiguration = {}, + publicApi = {}, + addError = noop, +}: { + configuration?: VuePluginConfiguration + initConfiguration?: Partial + publicApi?: Partial + addError?: StartRumResult['addError'] +} = {}) { + resetVuePlugin() + const plugin = vuePlugin(configuration) + plugin.onInit({ + publicApi: publicApi as RumPublicApi, + initConfiguration: initConfiguration as RumInitConfiguration, + }) + plugin.onRumStart({ addError }) + registerCleanupTask(() => resetVuePlugin()) +}