Skip to content
Merged
1 change: 1 addition & 0 deletions packages/core/src/tools/stackTrace/handlingStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function createHandlingStack(
| 'react error'
| 'nextjs error'
| 'vue error'
| 'angular error'
| 'view'
| 'vital'
): string {
Expand Down
63 changes: 63 additions & 0 deletions packages/rum-angular/src/domain/error/addAngularError.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { initializeAngularPlugin } from '../../../test/initializeAngularPlugin'
import { addAngularError } from './addAngularError'

describe('addAngularError', () => {
it('delegates the error to addError', () => {
const addErrorSpy = jasmine.createSpy()
initializeAngularPlugin({
addError: addErrorSpy,
})
const originalError = new Error('error message')

addAngularError(originalError)

expect(addErrorSpy).toHaveBeenCalledOnceWith({
error: originalError,
handlingStack: jasmine.any(String),
startClocks: jasmine.any(Object),
context: {
framework: 'angular',
},
})
})

it('should merge dd_context from the original error with angular error context', () => {
const addErrorSpy = jasmine.createSpy()
initializeAngularPlugin({
addError: addErrorSpy,
})
const originalError = new Error('error message')
;(originalError as any).dd_context = { component: 'UserList', param: 42 }

addAngularError(originalError)

expect(addErrorSpy).toHaveBeenCalledWith(
jasmine.objectContaining({
error: originalError,
context: {
framework: 'angular',
component: 'UserList',
param: 42,
},
})
)
})

it('handles non-Error values', () => {
const addErrorSpy = jasmine.createSpy()
initializeAngularPlugin({
addError: addErrorSpy,
})

addAngularError('string error')

expect(addErrorSpy).toHaveBeenCalledOnceWith(
jasmine.objectContaining({
error: 'string error',
handlingStack: jasmine.any(String),
startClocks: jasmine.any(Object),
context: { framework: 'angular' },
})
)
})
})
39 changes: 39 additions & 0 deletions packages/rum-angular/src/domain/error/addAngularError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Context } from '@datadog/browser-core'
import { callMonitored, clocksNow, createHandlingStack } from '@datadog/browser-core'
import { onRumStart } from '../angularPlugin'

/**
* Add an Angular error to the RUM session.
*
* This function is used internally by `provideDatadogErrorHandler()`, but can also be called
* directly to report errors caught by custom error handling logic.
*
* @category Error
* @example
* ```ts
* import { addAngularError } from '@datadog/browser-rum-angular'
*
* // In a custom ErrorHandler
* handleError(error: any) {
* addAngularError(error)
* // your own error handling...
* }
* ```
*/
export function addAngularError(error: unknown) {
const handlingStack = createHandlingStack('angular error')
const startClocks = clocksNow()
onRumStart((addError) => {
callMonitored(() => {
addError({
error,
handlingStack,
startClocks,
context: {
...(typeof error === 'object' && error !== null ? (error as { dd_context?: Context }).dd_context : undefined),
Comment thread
BeltranBulbarellaDD marked this conversation as resolved.
framework: 'angular',
},
})
})
})
}
2 changes: 2 additions & 0 deletions packages/rum-angular/src/domain/error/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { addAngularError } from './addAngularError'
export { provideDatadogErrorHandler } from './provideDatadogErrorHandler'
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { EnvironmentInjector } from '@angular/core'
import { ErrorHandler, Injector, createEnvironmentInjector } from '@angular/core'
import { initializeAngularPlugin } from '../../../test/initializeAngularPlugin'
import { provideDatadogErrorHandler } from './provideDatadogErrorHandler'

function createErrorHandler(): ErrorHandler {
const injector = createEnvironmentInjector([provideDatadogErrorHandler()], Injector.NULL as EnvironmentInjector)
return injector.get(ErrorHandler)
}

describe('provideDatadogErrorHandler', () => {
it('provides an ErrorHandler that reports errors to Datadog', () => {
const addErrorSpy = jasmine.createSpy()
initializeAngularPlugin({ addError: addErrorSpy })

const handler = createErrorHandler()
handler.handleError(new Error('test error'))

expect(addErrorSpy).toHaveBeenCalled()
})

it('still logs the error to the console via default ErrorHandler', () => {
initializeAngularPlugin()

const consoleErrorSpy = spyOn(console, 'error')
const handler = createErrorHandler()
handler.handleError(new Error('test error'))

expect(consoleErrorSpy).toHaveBeenCalled()
})
})
Comment thread
rgaignault marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { EnvironmentProviders } from '@angular/core'
// eslint-disable-next-line local-rules/disallow-side-effects
import { ErrorHandler, makeEnvironmentProviders } from '@angular/core'
import { addAngularError } from './addAngularError'

// eslint-disable-next-line no-restricted-syntax
class DatadogErrorHandler extends ErrorHandler {
override handleError(error: unknown): void {
addAngularError(error)
super.handleError(error)
}
}

/**
* Provides a Datadog-instrumented Angular ErrorHandler that reports errors to RUM.
*
* @category Error
* @example
* ```ts
* import { provideDatadogErrorHandler } from '@datadog/browser-rum-angular'
*
* bootstrapApplication(AppComponent, {
* providers: [provideDatadogErrorHandler()],
* })
* ```
*/
export function provideDatadogErrorHandler(): EnvironmentProviders {
return makeEnvironmentProviders([{ provide: ErrorHandler, useClass: DatadogErrorHandler }])
}
1 change: 1 addition & 0 deletions packages/rum-angular/src/entries/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { angularPlugin } from '../domain/angularPlugin'
export type { AngularPluginConfiguration } from '../domain/angularPlugin'
export { provideDatadogRouter } from '../domain/angularRouter/provideDatadogRouter'
export { addAngularError, provideDatadogErrorHandler } from '../domain/error'
23 changes: 23 additions & 0 deletions packages/rum-angular/test/initializeAngularPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { RumInitConfiguration, RumPublicApi, StartRumResult } from '@datadog/browser-rum-core'
import { noop } from '@datadog/browser-core'
import { angularPlugin, resetAngularPlugin } from '../src/domain/angularPlugin'
import { registerCleanupTask } from '../../core/test'

export function initializeAngularPlugin({
addError = noop,
}: {
addError?: StartRumResult['addError']
} = {}) {
resetAngularPlugin()
const plugin = angularPlugin()

plugin.onInit!({
publicApi: {} as RumPublicApi,
initConfiguration: {} as RumInitConfiguration,
})
plugin.onRumStart!({ addError })

registerCleanupTask(() => {
resetAngularPlugin()
})
}
20 changes: 16 additions & 4 deletions test/apps/angular-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Component } from '@angular/core'
import { bootstrapApplication } from '@angular/platform-browser'
import { provideRouter, RouterOutlet, RouterLink, type Routes } from '@angular/router'
import { datadogRum } from '@datadog/browser-rum'
import { angularPlugin, provideDatadogRouter } from '@datadog/browser-rum-angular'
import { angularPlugin, provideDatadogRouter, provideDatadogErrorHandler } from '@datadog/browser-rum-angular'

declare global {
interface Window {
Expand All @@ -28,10 +28,22 @@ if (window.RUM_CONTEXT) {
<h1>Initial Route</h1>
<a routerLink="/parameterized/42">Go to Parameterized Route</a><br />
<a routerLink="/parent/nested">Go to Nested Route</a><br />
<a routerLink="/unknown/page">Go to Wildcard Route</a>
<a routerLink="/unknown/page">Go to Wildcard Route</a><br />
<button id="throw-error" (click)="throwError()">Throw Error</button>
<button id="throw-error-with-context" (click)="throwErrorWithContext()">Throw Error With Context</button>
`,
})
class InitialRouteComponent {}
class InitialRouteComponent {
throwError() {
throw new Error('angular error from component')
}

throwErrorWithContext() {
const error = new Error('angular error with dd_context')
;(error as any).dd_context = { component: 'InitialRoute', userId: 42 }
throw error
}
}

@Component({
selector: 'app-parameterized-route',
Expand Down Expand Up @@ -83,5 +95,5 @@ const rootElement = document.createElement('app-root')
document.body.appendChild(rootElement)

void bootstrapApplication(AppComponent, {
providers: [provideRouter(routes), provideDatadogRouter()],
providers: [provideRouter(routes), provideDatadogRouter(), provideDatadogErrorHandler()],
})
42 changes: 42 additions & 0 deletions test/e2e/scenario/angularPlugin.scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,46 @@ test.describe('angular plugin', () => {
const firstView = viewEvents[0]
expect(firstView.view.name).toBe('/')
})

createTest('should report errors caught by provideDatadogErrorHandler')
.withRum()
.withApp('angular-app')
.run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => {
await page.click('#throw-error')
await flushEvents()

const angularErrors = intakeRegistry.rumErrorEvents.filter((event) => event.context?.framework === 'angular')
expect(angularErrors).toHaveLength(1)
expect(angularErrors[0].error.message).toBe('angular error from component')
expect(angularErrors[0].error.handling).toBe('handled')
expect(angularErrors[0].error.source).toBe('custom')
expect(angularErrors[0].error.handling_stack).toEqual(expect.stringContaining('angular error'))

withBrowserLogs((browserLogs) => {
expect(browserLogs.filter((log) => log.level === 'error').length).toBeGreaterThan(0)
})
})

createTest('should merge dd_context from the error object into the event context')
.withRum()
.withApp('angular-app')
.run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => {
await page.click('#throw-error-with-context')
await flushEvents()

const angularErrors = intakeRegistry.rumErrorEvents.filter((event) => event.context?.framework === 'angular')
expect(angularErrors).toHaveLength(1)
expect(angularErrors[0].error.message).toBe('angular error with dd_context')
expect(angularErrors[0].context).toEqual(
expect.objectContaining({
framework: 'angular',
component: 'InitialRoute',
userId: 42,
})
)

withBrowserLogs((browserLogs) => {
expect(browserLogs.filter((log) => log.level === 'error').length).toBeGreaterThan(0)
})
})
})
6 changes: 6 additions & 0 deletions test/unit/globalThisPolyfill.js
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ question: ‏Why do you need this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@angular/core uses globalThis directly (without a guard), and that throw a reference error on chrome 63

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting :)
Out of curiosity, do you have the reference in @angular/core where it’s used?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any other idea to handle this ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum weird, error comes from afterAll.
Anyway, you could try this on the test related to the error:

 .withHead(html`
      <script>
        if (typeof globalThis === 'undefined') {
          window.globalThis = window
        }
      </script>
    `)

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Polyfill globalThis for browsers that don't support it (e.g. Chrome < 71)
// Required because @angular/core uses globalThis internally.
/* eslint-disable no-undef */
if (typeof globalThis === 'undefined') {
window.globalThis = window
}
6 changes: 5 additions & 1 deletion test/unit/karma.base.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ if (testReportDirectory) {
}

const FILES = [
// Polyfill globalThis for older browsers (e.g. Chrome 63) that don't support it.
// Required because @angular/core uses globalThis internally.
{ pattern: 'test/unit/globalThisPolyfill.js', watched: false },
// Make sure 'forEach.spec' is the first file to be loaded, so its `beforeEach` hook is executed
// before all other `beforeEach` hooks, and its `afterEach` hook is executed after all other
// `afterEach` hooks.
Expand Down Expand Up @@ -126,7 +129,8 @@ function overrideTsLoaderRule(module) {
// We use swc-loader to transpile some dependencies that are using syntax not compatible with browsers we use for testing
module.rules.push({
test: /\.m?js$/,
include: /node_modules\/(react|react-router-dom|react-dom|react-router|turbo-stream|vue-router|@vue\/test-utils)/,
include:
/node_modules\/(@angular\/core|react|react-router-dom|react-dom|react-router|turbo-stream|vue-router|@vue\/test-utils)/,
use: {
loader: 'swc-loader',
options: {
Expand Down
Loading