Skip to content

Commit fdd1422

Browse files
committed
✨ add vuePlugin and addVueError
1 parent 3d20c79 commit fdd1422

7 files changed

Lines changed: 242 additions & 2 deletions

File tree

packages/core/src/tools/stackTrace/handlingStack.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@ import { computeStackTrace } from './computeStackTrace'
99
* - No monitored function should encapsulate it, that is why we need to use callMonitored inside it.
1010
*/
1111
export function createHandlingStack(
12-
type: 'console error' | 'action' | 'error' | 'instrumented method' | 'log' | 'react error' | 'view' | 'vital'
12+
type:
13+
| 'console error'
14+
| 'action'
15+
| 'error'
16+
| 'instrumented method'
17+
| 'log'
18+
| 'react error'
19+
| 'vue error'
20+
| 'view'
21+
| 'vital'
1322
): string {
1423
/**
1524
* Skip the two internal frames:
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { initializeVuePlugin } from '../../../test/initializeVuePlugin'
2+
import { addVueError } from './addVueError'
3+
4+
describe('addVueError', () => {
5+
it('reports the error to the SDK', () => {
6+
const addErrorSpy = jasmine.createSpy()
7+
initializeVuePlugin({ addError: addErrorSpy })
8+
9+
const error = new Error('something broke')
10+
addVueError(error, null, 'mounted hook')
11+
12+
expect(addErrorSpy).toHaveBeenCalledOnceWith(
13+
jasmine.objectContaining({
14+
error,
15+
handlingStack: jasmine.any(String),
16+
componentStack: 'mounted hook',
17+
startClocks: jasmine.any(Object),
18+
context: { framework: 'vue' },
19+
})
20+
)
21+
})
22+
23+
it('handles empty info gracefully', () => {
24+
const addErrorSpy = jasmine.createSpy()
25+
initializeVuePlugin({ addError: addErrorSpy })
26+
addVueError(new Error('oops'), null, '')
27+
expect(addErrorSpy).toHaveBeenCalledTimes(1)
28+
expect(addErrorSpy.calls.mostRecent().args[0].componentStack).toBeUndefined()
29+
})
30+
31+
it('should merge dd_context from the original error with vue error context', () => {
32+
const addErrorSpy = jasmine.createSpy()
33+
initializeVuePlugin({ addError: addErrorSpy })
34+
const originalError = new Error('error message')
35+
;(originalError as any).dd_context = { component: 'Menu', param: 123 }
36+
37+
addVueError(originalError, null, 'mounted hook')
38+
39+
expect(addErrorSpy.calls.mostRecent().args[0].context).toEqual({
40+
framework: 'vue',
41+
component: 'Menu',
42+
param: 123,
43+
})
44+
})
45+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { ComponentPublicInstance } from 'vue'
2+
import { callMonitored, clocksNow, createHandlingStack } from '@datadog/browser-core'
3+
import type { Context } from '@datadog/browser-core'
4+
import { onVueStart } from '../vuePlugin'
5+
6+
/**
7+
* Add a Vue error to the RUM session.
8+
*
9+
* @category Error
10+
* @example
11+
* ```ts
12+
* import { createApp } from 'vue'
13+
* import { addVueError } from '@datadog/browser-rum-vue'
14+
*
15+
* const app = createApp(App)
16+
* // Report all Vue errors to Datadog automatically
17+
* app.config.errorHandler = addVueError
18+
* ```
19+
*/
20+
export function addVueError(
21+
error: unknown,
22+
// Required by Vue's app.config.errorHandler signature, but not used by the SDK
23+
_instance: ComponentPublicInstance | null,
24+
info: string
25+
) {
26+
const handlingStack = createHandlingStack('vue error')
27+
const startClocks = clocksNow()
28+
onVueStart((addError) => {
29+
callMonitored(() => {
30+
addError({
31+
error,
32+
handlingStack,
33+
componentStack: info || undefined,
34+
startClocks,
35+
context: {
36+
...(error as Error & { dd_context?: Context }).dd_context,
37+
framework: 'vue',
38+
},
39+
})
40+
})
41+
})
42+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { RumInitConfiguration, RumPublicApi } from '@datadog/browser-rum-core'
2+
import { registerCleanupTask } from '../../../core/test'
3+
import { onVueInit, vuePlugin, resetVuePlugin } from './vuePlugin'
4+
5+
const PUBLIC_API = {} as RumPublicApi
6+
const INIT_CONFIGURATION = {} as RumInitConfiguration
7+
8+
describe('vuePlugin', () => {
9+
beforeEach(() => {
10+
registerCleanupTask(() => resetVuePlugin())
11+
})
12+
13+
it('returns a plugin object with name "vue"', () => {
14+
expect(vuePlugin()).toEqual(jasmine.objectContaining({ name: 'vue' }))
15+
})
16+
17+
it('calls callbacks registered with onVueInit during onInit', () => {
18+
const spy = jasmine.createSpy()
19+
const config = {}
20+
onVueInit(spy)
21+
vuePlugin(config).onInit({ publicApi: PUBLIC_API, initConfiguration: INIT_CONFIGURATION })
22+
expect(spy).toHaveBeenCalledOnceWith(config, PUBLIC_API)
23+
})
24+
25+
it('calls callbacks immediately if onInit was already invoked', () => {
26+
const spy = jasmine.createSpy()
27+
const config = {}
28+
vuePlugin(config).onInit({ publicApi: PUBLIC_API, initConfiguration: INIT_CONFIGURATION })
29+
onVueInit(spy)
30+
expect(spy).toHaveBeenCalledOnceWith(config, PUBLIC_API)
31+
})
32+
33+
it('sets trackViewsManually when router is true', () => {
34+
const initConfiguration = { ...INIT_CONFIGURATION }
35+
vuePlugin({ router: true }).onInit({ publicApi: PUBLIC_API, initConfiguration })
36+
expect(initConfiguration.trackViewsManually).toBe(true)
37+
})
38+
39+
it('does not set trackViewsManually when router is false', () => {
40+
const initConfiguration = { ...INIT_CONFIGURATION }
41+
vuePlugin({ router: false }).onInit({ publicApi: PUBLIC_API, initConfiguration })
42+
expect(initConfiguration.trackViewsManually).toBeUndefined()
43+
})
44+
45+
it('returns configuration telemetry', () => {
46+
expect(vuePlugin({ router: true }).getConfigurationTelemetry()).toEqual({ router: true })
47+
})
48+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { RumPlugin, RumPublicApi, StartRumResult } from '@datadog/browser-rum-core'
2+
3+
let globalPublicApi: RumPublicApi | undefined
4+
let globalConfiguration: VuePluginConfiguration | undefined
5+
let globalAddError: StartRumResult['addError'] | undefined
6+
7+
type InitSubscriber = (configuration: VuePluginConfiguration, rumPublicApi: RumPublicApi) => void
8+
type StartSubscriber = (addError: StartRumResult['addError']) => void
9+
10+
const onRumInitSubscribers: InitSubscriber[] = []
11+
const onRumStartSubscribers: StartSubscriber[] = []
12+
13+
export interface VuePluginConfiguration {
14+
router?: boolean
15+
}
16+
17+
export type VuePlugin = Required<RumPlugin>
18+
19+
export function vuePlugin(configuration: VuePluginConfiguration = {}): VuePlugin {
20+
return {
21+
name: 'vue',
22+
onInit({ publicApi, initConfiguration }) {
23+
globalPublicApi = publicApi
24+
globalConfiguration = configuration
25+
for (const subscriber of onRumInitSubscribers) {
26+
subscriber(globalConfiguration, globalPublicApi)
27+
}
28+
if (configuration.router) {
29+
initConfiguration.trackViewsManually = true
30+
}
31+
},
32+
onRumStart({ addError }) {
33+
globalAddError = addError
34+
for (const subscriber of onRumStartSubscribers) {
35+
if (addError) {
36+
subscriber(addError)
37+
}
38+
}
39+
},
40+
getConfigurationTelemetry() {
41+
return { router: !!configuration.router }
42+
},
43+
} satisfies RumPlugin
44+
}
45+
46+
export function onVueInit(callback: InitSubscriber) {
47+
if (globalConfiguration && globalPublicApi) {
48+
callback(globalConfiguration, globalPublicApi)
49+
} else {
50+
onRumInitSubscribers.push(callback)
51+
}
52+
}
53+
54+
export function onVueStart(callback: StartSubscriber) {
55+
if (globalAddError) {
56+
callback(globalAddError)
57+
} else {
58+
onRumStartSubscribers.push(callback)
59+
}
60+
}
61+
62+
export function resetVuePlugin() {
63+
globalPublicApi = undefined
64+
globalConfiguration = undefined
65+
globalAddError = undefined
66+
onRumInitSubscribers.length = 0
67+
onRumStartSubscribers.length = 0
68+
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
export {}
1+
export type { VuePluginConfiguration, VuePlugin } from '../domain/vuePlugin'
2+
export { vuePlugin } from '../domain/vuePlugin'
3+
export { addVueError } from '../domain/error/addVueError'
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { RumInitConfiguration, RumPublicApi, StartRumResult } from '@datadog/browser-rum-core'
2+
import { noop } from '@datadog/browser-core'
3+
import type { VuePluginConfiguration } from '../src/domain/vuePlugin'
4+
import { vuePlugin, resetVuePlugin } from '../src/domain/vuePlugin'
5+
import { registerCleanupTask } from '../../core/test'
6+
7+
export function initializeVuePlugin({
8+
configuration = {},
9+
initConfiguration = {},
10+
publicApi = {},
11+
addError = noop,
12+
}: {
13+
configuration?: VuePluginConfiguration
14+
initConfiguration?: Partial<RumInitConfiguration>
15+
publicApi?: Partial<RumPublicApi>
16+
addError?: StartRumResult['addError']
17+
} = {}) {
18+
resetVuePlugin()
19+
const plugin = vuePlugin(configuration)
20+
plugin.onInit({
21+
publicApi: publicApi as RumPublicApi,
22+
initConfiguration: initConfiguration as RumInitConfiguration,
23+
})
24+
plugin.onRumStart({ addError })
25+
registerCleanupTask(() => resetVuePlugin())
26+
}

0 commit comments

Comments
 (0)