diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index bcbef8e06a8e..a846c6f90e77 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -994,6 +994,7 @@ jobs:
test-application:
[
'angular-17',
+ 'angular-18',
'cloudflare-astro',
'node-express',
'create-react-app',
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/.editorconfig b/dev-packages/e2e-tests/test-applications/angular-18/.editorconfig
new file mode 100644
index 000000000000..59d9a3a3e73f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/.editorconfig
@@ -0,0 +1,16 @@
+# Editor configuration, see https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.ts]
+quote_type = single
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/.gitignore b/dev-packages/e2e-tests/test-applications/angular-18/.gitignore
new file mode 100644
index 000000000000..0711527ef9d5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/.gitignore
@@ -0,0 +1,42 @@
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+# Compiled output
+/dist
+/tmp
+/out-tsc
+/bazel-out
+
+# Node
+/node_modules
+npm-debug.log
+yarn-error.log
+
+# IDEs and editors
+.idea/
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# Visual Studio Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.history/*
+
+# Miscellaneous
+/.angular/cache
+.sass-cache/
+/connect.lock
+/coverage
+/libpeerconnection.log
+testem.log
+/typings
+
+# System files
+.DS_Store
+Thumbs.db
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/.npmrc b/dev-packages/e2e-tests/test-applications/angular-18/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/README.md b/dev-packages/e2e-tests/test-applications/angular-18/README.md
new file mode 100644
index 000000000000..5a2f2e67c868
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/README.md
@@ -0,0 +1,3 @@
+# Angular 18
+
+E2E test app for Angular 18 and `@sentry/angular`.
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/angular.json b/dev-packages/e2e-tests/test-applications/angular-18/angular.json
new file mode 100644
index 000000000000..cb3c0b70cec6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/angular.json
@@ -0,0 +1,95 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "angular-18": {
+ "projectType": "application",
+ "schematics": {},
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:application",
+ "options": {
+ "outputPath": "dist/angular-18",
+ "index": "src/index.html",
+ "browser": "src/main.ts",
+ "polyfills": [
+ "zone.js"
+ ],
+ "tsConfig": "tsconfig.app.json",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets"
+ ],
+ "styles": [
+ "src/styles.css"
+ ],
+ "scripts": []
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kb",
+ "maximumError": "1mb"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "2kb",
+ "maximumError": "4kb"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular-devkit/build-angular:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "angular-18:build:production"
+ },
+ "development": {
+ "buildTarget": "angular-18:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "extract-i18n": {
+ "builder": "@angular-devkit/build-angular:extract-i18n",
+ "options": {
+ "buildTarget": "angular-18:build"
+ }
+ },
+ "test": {
+ "builder": "@angular-devkit/build-angular:karma",
+ "options": {
+ "polyfills": [
+ "zone.js",
+ "zone.js/testing"
+ ],
+ "tsConfig": "tsconfig.spec.json",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets"
+ ],
+ "styles": [
+ "src/styles.css"
+ ],
+ "scripts": []
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/package.json b/dev-packages/e2e-tests/test-applications/angular-18/package.json
new file mode 100644
index 000000000000..1c2bf8b723cb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "angular-18",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "dev": "ng serve",
+ "proxy": "node start-event-proxy.mjs",
+ "preview": "http-server dist/angular-18/browser --port 8080",
+ "build": "ng build",
+ "watch": "ng build --watch --configuration development",
+ "test": "playwright test",
+ "test:build": "pnpm install && npx playwright install && pnpm build",
+ "test:assert": "playwright test",
+ "clean": "npx rimraf .angular node_modules pnpm-lock.yaml dist"
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/animations": "^18.0.0",
+ "@angular/common": "^18.0.0",
+ "@angular/compiler": "^18.0.0",
+ "@angular/core": "^18.0.0",
+ "@angular/forms": "^18.0.0",
+ "@angular/platform-browser": "^18.0.0",
+ "@angular/platform-browser-dynamic": "^18.0.0",
+ "@angular/router": "^18.0.0",
+ "@sentry/angular": "* || latest",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "zone.js": "~0.14.3"
+ },
+ "devDependencies": {
+ "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server",
+ "@angular-devkit/build-angular": "^18.0.0",
+ "@angular/cli": "^18.0.0",
+ "@angular/compiler-cli": "^18.0.0",
+ "@playwright/test": "^1.41.1",
+ "@types/jasmine": "~5.1.0",
+ "http-server": "^14.1.1",
+ "jasmine-core": "~5.1.0",
+ "karma": "~6.4.0",
+ "karma-chrome-launcher": "~3.2.0",
+ "karma-coverage": "~2.2.0",
+ "karma-jasmine": "~5.1.0",
+ "karma-jasmine-html-reporter": "~2.1.0",
+ "typescript": "~5.4.5",
+ "wait-port": "1.0.4"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts b/dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts
new file mode 100644
index 000000000000..df7c2d9758e9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/playwright.config.ts
@@ -0,0 +1,77 @@
+import type { PlaywrightTestConfig } from '@playwright/test';
+import { devices } from '@playwright/test';
+
+// Fix urls not resolving to localhost on Node v17+
+// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575
+import { setDefaultResultOrder } from 'dns';
+setDefaultResultOrder('ipv4first');
+
+const testEnv = process.env['TEST_ENV'] || 'production';
+
+if (!testEnv) {
+ throw new Error('No test env defined');
+}
+
+const angularPort = 8080;
+const eventProxyPort = 3031;
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+const config: PlaywrightTestConfig = {
+ testDir: './tests',
+ /* Maximum time one test can run for. */
+ timeout: 150_000,
+ expect: {
+ /**
+ * Maximum time expect() should wait for the condition to be met.
+ * For example in `await expect(locator).toHaveText();`
+ */
+ timeout: 10000,
+ },
+ fullyParallel: false,
+ workers: 1,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* `next dev` is incredibly buggy with the app dir */
+ retries: testEnv === 'development' ? 3 : 0,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: 'list',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
+ actionTimeout: 0,
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL: `http://localhost:${angularPort}`,
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ },
+ },
+ ],
+
+ /* Run your local dev server before starting the tests */
+ webServer: [
+ {
+ command: 'node start-event-proxy.mjs',
+ port: eventProxyPort,
+ },
+ {
+ command:
+ testEnv === 'development'
+ ? `pnpm wait-port ${eventProxyPort} && pnpm preview -p ${angularPort}`
+ : `pnpm wait-port ${eventProxyPort} && pnpm preview -p ${angularPort}`,
+ port: angularPort,
+ },
+ ],
+};
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.component.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.component.ts
new file mode 100644
index 000000000000..ab3efd7e16f3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.component.ts
@@ -0,0 +1,12 @@
+import { Component } from '@angular/core';
+import { RouterOutlet } from '@angular/router';
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [RouterOutlet],
+ template: ``,
+})
+export class AppComponent {
+ title = 'angular-18';
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts
new file mode 100644
index 000000000000..8267759c8ba1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.config.ts
@@ -0,0 +1,25 @@
+import { APP_INITIALIZER, ApplicationConfig, ErrorHandler } from '@angular/core';
+import { Router, provideRouter } from '@angular/router';
+
+import { TraceService, createErrorHandler } from '@sentry/angular';
+import { routes } from './app.routes';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideRouter(routes),
+ {
+ provide: ErrorHandler,
+ useValue: createErrorHandler(),
+ },
+ {
+ provide: TraceService,
+ deps: [Router],
+ },
+ {
+ provide: APP_INITIALIZER,
+ useFactory: () => () => {},
+ deps: [TraceService],
+ multi: true,
+ },
+ ],
+};
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts
new file mode 100644
index 000000000000..24bf8b769051
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/app.routes.ts
@@ -0,0 +1,42 @@
+import { Routes } from '@angular/router';
+import { cancelGuard } from './cancel-guard.guard';
+import { CancelComponent } from './cancel/cancel.components';
+import { ComponentTrackingComponent } from './component-tracking/component-tracking.components';
+import { HomeComponent } from './home/home.component';
+import { UserComponent } from './user/user.component';
+
+export const routes: Routes = [
+ {
+ path: 'users/:id',
+ component: UserComponent,
+ },
+ {
+ path: 'home',
+ component: HomeComponent,
+ },
+ {
+ path: 'cancel',
+ component: CancelComponent,
+ canActivate: [cancelGuard],
+ },
+ {
+ path: 'component-tracking',
+ component: ComponentTrackingComponent,
+ },
+ {
+ path: 'redirect1',
+ redirectTo: '/redirect2',
+ },
+ {
+ path: 'redirect2',
+ redirectTo: '/redirect3',
+ },
+ {
+ path: 'redirect3',
+ redirectTo: '/users/456',
+ },
+ {
+ path: '**',
+ redirectTo: 'home',
+ },
+];
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts
new file mode 100644
index 000000000000..16ec4a2ab164
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel-guard.guard.ts
@@ -0,0 +1,5 @@
+import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
+
+export const cancelGuard: CanActivateFn = (_next: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
+ return false;
+};
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts
new file mode 100644
index 000000000000..b6ee1876e035
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/cancel/cancel.components.ts
@@ -0,0 +1,8 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-cancel',
+ standalone: true,
+ template: `
`,
+})
+export class CancelComponent {}
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts
new file mode 100644
index 000000000000..d437a1d43fdd
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/component-tracking/component-tracking.components.ts
@@ -0,0 +1,18 @@
+import { AfterViewInit, Component, OnInit } from '@angular/core';
+import { TraceClass, TraceMethod, TraceModule } from '@sentry/angular';
+import { SampleComponent } from '../sample-component/sample-component.components';
+
+@Component({
+ selector: 'app-cancel',
+ standalone: true,
+ imports: [TraceModule, SampleComponent],
+ template: ``,
+})
+@TraceClass({ name: 'ComponentTrackingComponent' })
+export class ComponentTrackingComponent implements OnInit, AfterViewInit {
+ @TraceMethod({ name: 'ngOnInit' })
+ ngOnInit() {}
+
+ @TraceMethod()
+ ngAfterViewInit() {}
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts
new file mode 100644
index 000000000000..9f36814d6c03
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/home/home.component.ts
@@ -0,0 +1,26 @@
+import { Component } from '@angular/core';
+import { RouterLink } from '@angular/router';
+
+@Component({
+ selector: 'app-home',
+ standalone: true,
+ imports: [RouterLink],
+ template: `
+
+ Welcome to Sentry's Angular 18 E2E test app
+
+
+
+`,
+})
+export class HomeComponent {
+ throwError() {
+ throw new Error('Error thrown from Angular 18 E2E test app');
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts
new file mode 100644
index 000000000000..da09425c7565
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/sample-component/sample-component.components.ts
@@ -0,0 +1,12 @@
+import { Component, OnInit } from '@angular/core';
+
+@Component({
+ selector: 'app-sample-component',
+ standalone: true,
+ template: `Component
`,
+})
+export class SampleComponent implements OnInit {
+ ngOnInit() {
+ console.log('SampleComponent');
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts
new file mode 100644
index 000000000000..db02568d395f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/app/user/user.component.ts
@@ -0,0 +1,25 @@
+import { AsyncPipe } from '@angular/common';
+import { Component } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { Observable, map } from 'rxjs';
+
+@Component({
+ selector: 'app-user',
+ standalone: true,
+ imports: [AsyncPipe],
+ template: `
+ Hello User {{ userId$ | async }}
+
+ `,
+})
+export class UserComponent {
+ public userId$: Observable;
+
+ constructor(private route: ActivatedRoute) {
+ this.userId$ = this.route.paramMap.pipe(map(params => params.get('id') || 'UNKNOWN USER'));
+ }
+
+ throwError() {
+ throw new Error('Error thrown from user page');
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico b/dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico
new file mode 100644
index 000000000000..57614f9c9675
Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/angular-18/src/favicon.ico differ
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/index.html b/dev-packages/e2e-tests/test-applications/angular-18/src/index.html
new file mode 100644
index 000000000000..075475aa383e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Angular 18
+
+
+
+
+
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/main.ts b/dev-packages/e2e-tests/test-applications/angular-18/src/main.ts
new file mode 100644
index 000000000000..947f40691b05
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/main.ts
@@ -0,0 +1,15 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { AppComponent } from './app/app.component';
+import { appConfig } from './app/app.config';
+
+import * as Sentry from '@sentry/angular';
+
+Sentry.init({
+ dsn: 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576',
+ tracesSampleRate: 1.0,
+ integrations: [Sentry.browserTracingIntegration({})],
+ tunnel: `http://localhost:3031/`, // proxy server
+ debug: true,
+});
+
+bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err));
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/src/styles.css b/dev-packages/e2e-tests/test-applications/angular-18/src/styles.css
new file mode 100644
index 000000000000..90d4ee0072ce
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/src/styles.css
@@ -0,0 +1 @@
+/* You can add global styles to this file, and also import other style files */
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs
new file mode 100644
index 000000000000..696f1807d8f1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/event-proxy-server';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'angular-18',
+});
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts
new file mode 100644
index 000000000000..a255d2130dda
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/tests/errors.test.ts
@@ -0,0 +1,65 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server';
+
+test('sends an error', async ({ page }) => {
+ const errorPromise = waitForError('angular-18', async errorEvent => {
+ return !errorEvent.type;
+ });
+
+ await page.goto(`/`);
+
+ await page.locator('#errorBtn').click();
+
+ const error = await errorPromise;
+
+ expect(error).toMatchObject({
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Error thrown from Angular 18 E2E test app',
+ mechanism: {
+ type: 'angular',
+ handled: false,
+ },
+ },
+ ],
+ },
+ transaction: '/home/',
+ });
+});
+
+test('assigns the correct transaction value after a navigation', async ({ page }) => {
+ const pageloadTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const errorPromise = waitForError('angular-18', async errorEvent => {
+ return !errorEvent.type;
+ });
+
+ await page.goto(`/`);
+ await pageloadTxnPromise;
+
+ await page.waitForTimeout(5000);
+
+ await page.locator('#navLink').click();
+
+ const [_, error] = await Promise.all([page.locator('#userErrorBtn').click(), errorPromise]);
+
+ expect(error).toMatchObject({
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Error thrown from user page',
+ mechanism: {
+ type: 'angular',
+ handled: false,
+ },
+ },
+ ],
+ },
+ transaction: '/users/:id/',
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts
new file mode 100644
index 000000000000..12f0fbd41133
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts
@@ -0,0 +1,313 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/event-proxy-server';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
+
+test('sends a pageload transaction with a parameterized URL', async ({ page }) => {
+ const transactionPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/`);
+
+ const rootSpan = await transactionPromise;
+
+ expect(rootSpan).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'pageload',
+ origin: 'auto.pageload.angular',
+ },
+ },
+ transaction: '/home/',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
+
+test('sends a navigation transaction with a parameterized URL', async ({ page }) => {
+ const pageloadTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+ await pageloadTxnPromise;
+
+ await page.waitForTimeout(5000);
+
+ const [_, navigationTxn] = await Promise.all([page.locator('#navLink').click(), navigationTxnPromise]);
+
+ expect(navigationTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ },
+ },
+ transaction: '/users/:id/',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
+
+test('sends a navigation transaction even if the pageload span is still active', async ({ page }) => {
+ const pageloadTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+
+ // immediately navigate to a different route
+ const [_, pageloadTxn, navigationTxn] = await Promise.all([
+ page.locator('#navLink').click(),
+ pageloadTxnPromise,
+ navigationTxnPromise,
+ ]);
+
+ expect(pageloadTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'pageload',
+ origin: 'auto.pageload.angular',
+ },
+ },
+ transaction: '/home/',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+
+ expect(navigationTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.angular',
+ },
+ },
+ transaction: '/users/:id/',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+});
+
+test('groups redirects within one navigation root span', async ({ page }) => {
+ const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+
+ // immediately navigate to a different route
+ const [_, navigationTxn] = await Promise.all([page.locator('#redirectLink').click(), navigationTxnPromise]);
+
+ expect(navigationTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.angular',
+ },
+ },
+ transaction: '/users/:id/',
+ transaction_info: {
+ source: 'route',
+ },
+ });
+
+ const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing');
+
+ expect(routingSpan).toBeDefined();
+ expect(routingSpan?.description).toBe('/redirect1');
+});
+
+test.describe('finish routing span', () => {
+ test('finishes routing span on navigation cancel', async ({ page }) => {
+ const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+
+ // immediately navigate to a different route
+ const [_, navigationTxn] = await Promise.all([page.locator('#cancelLink').click(), navigationTxnPromise]);
+
+ expect(navigationTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.angular',
+ },
+ },
+ transaction: '/cancel',
+ transaction_info: {
+ source: 'url',
+ },
+ });
+
+ const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing');
+
+ expect(routingSpan).toBeDefined();
+ expect(routingSpan?.description).toBe('/cancel');
+ });
+
+ test('finishes routing span on navigation error', async ({ page }) => {
+ const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+
+ // immediately navigate to a different route
+ const [_, navigationTxn] = await Promise.all([page.locator('#nonExistentLink').click(), navigationTxnPromise]);
+
+ const nonExistentRoute = '/non-existent';
+
+ expect(navigationTxn).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.angular',
+ },
+ },
+ transaction: nonExistentRoute,
+ transaction_info: {
+ source: 'url',
+ },
+ });
+
+ const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing');
+
+ expect(routingSpan).toBeDefined();
+ expect(routingSpan?.description).toBe(nonExistentRoute);
+ });
+});
+
+test.describe('TraceDirective', () => {
+ test('creates a child tracingSpan with component name as span name on ngOnInit', async ({ page }) => {
+ const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+
+ // immediately navigate to a different route
+ const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]);
+
+ const traceDirectiveSpan = navigationTxn.spans?.find(
+ span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_directive',
+ );
+
+ expect(traceDirectiveSpan).toBeDefined();
+ expect(traceDirectiveSpan).toEqual(
+ expect.objectContaining({
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive',
+ },
+ description: '',
+ op: 'ui.angular.init',
+ origin: 'auto.ui.angular.trace_directive',
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ }),
+ );
+ });
+});
+
+test.describe('TraceClass Decorator', () => {
+ test('adds init span for decorated class', async ({ page }) => {
+ const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+
+ // immediately navigate to a different route
+ const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]);
+
+ const classDecoratorSpan = navigationTxn.spans?.find(
+ span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_class_decorator',
+ );
+
+ expect(classDecoratorSpan).toBeDefined();
+ expect(classDecoratorSpan).toEqual(
+ expect.objectContaining({
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_class_decorator',
+ },
+ description: '',
+ op: 'ui.angular.init',
+ origin: 'auto.ui.angular.trace_class_decorator',
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ }),
+ );
+ });
+});
+
+test.describe('TraceMethod Decorator', () => {
+ test('adds name to span description of decorated method `ngOnInit`', async ({ page }) => {
+ const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+
+ // immediately navigate to a different route
+ const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]);
+
+ const ngInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngOnInit');
+
+ expect(ngInitSpan).toBeDefined();
+ expect(ngInitSpan).toEqual(
+ expect.objectContaining({
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngOnInit',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator',
+ },
+ description: '',
+ op: 'ui.angular.ngOnInit',
+ origin: 'auto.ui.angular.trace_method_decorator',
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ }),
+ );
+ });
+
+ test('adds fallback name to span description of decorated method `ngAfterViewInit`', async ({ page }) => {
+ const navigationTxnPromise = waitForTransaction('angular-18', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto(`/`);
+
+ // immediately navigate to a different route
+ const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]);
+
+ const ngAfterViewInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngAfterViewInit');
+
+ expect(ngAfterViewInitSpan).toBeDefined();
+ expect(ngAfterViewInitSpan).toEqual(
+ expect.objectContaining({
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngAfterViewInit',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator',
+ },
+ description: '',
+ op: 'ui.angular.ngAfterViewInit',
+ origin: 'auto.ui.angular.trace_method_decorator',
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ }),
+ );
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.app.json
new file mode 100644
index 000000000000..374cc9d294aa
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.app.json
@@ -0,0 +1,14 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": []
+ },
+ "files": [
+ "src/main.ts"
+ ],
+ "include": [
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json
new file mode 100644
index 000000000000..f37b67ff0277
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-18/tsconfig.json
@@ -0,0 +1,33 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "sourceMap": true,
+ "declaration": false,
+ "experimentalDecorators": true,
+ "moduleResolution": "node",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "useDefineForClassFields": false,
+ "lib": [
+ "ES2022",
+ "dom"
+ ]
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/packages/angular/package.json b/packages/angular/package.json
index 5984a62c62f3..d0a363fc8d45 100644
--- a/packages/angular/package.json
+++ b/packages/angular/package.json
@@ -15,9 +15,9 @@
"access": "public"
},
"peerDependencies": {
- "@angular/common": ">= 14.x <= 17.x",
- "@angular/core": ">= 14.x <= 17.x",
- "@angular/router": ">= 14.x <= 17.x",
+ "@angular/common": ">= 14.x <= 18.x",
+ "@angular/core": ">= 14.x <= 18.x",
+ "@angular/router": ">= 14.x <= 18.x",
"rxjs": "^6.5.5 || ^7.x"
},
"dependencies": {