diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ae4095e304ab..9577eb15aff7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -886,7 +886,13 @@ jobs:
- uses: pnpm/action-setup@v4
with:
version: 9.4.0
+ - name: Set up Node for Angular 20
+ if: matrix.test-application == 'angular-20'
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.19.2'
- name: Set up Node
+ if: matrix.test-application != 'angular-20'
uses: actions/setup-node@v4
with:
node-version-file: 'dev-packages/e2e-tests/package.json'
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/.editorconfig b/dev-packages/e2e-tests/test-applications/angular-20/.editorconfig
new file mode 100644
index 000000000000..f166060da1cb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/.editorconfig
@@ -0,0 +1,17 @@
+# 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
+ij_typescript_use_double_quotes = false
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/.gitignore b/dev-packages/e2e-tests/test-applications/angular-20/.gitignore
new file mode 100644
index 000000000000..315c644a53e8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/.gitignore
@@ -0,0 +1,44 @@
+# See https://docs.github.com/get-started/getting-started-with-git/ignoring-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
+
+test-results
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/.npmrc b/dev-packages/e2e-tests/test-applications/angular-20/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/.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-20/README.md b/dev-packages/e2e-tests/test-applications/angular-20/README.md
new file mode 100644
index 000000000000..5798a982a95c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/README.md
@@ -0,0 +1,3 @@
+# Angular 20
+
+E2E test app for Angular 20 and `@sentry/angular`.
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/angular.json b/dev-packages/e2e-tests/test-applications/angular-20/angular.json
new file mode 100644
index 000000000000..09939b0f9b23
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/angular.json
@@ -0,0 +1,87 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "angular-20": {
+ "projectType": "application",
+ "schematics": {},
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:application",
+ "options": {
+ "outputPath": "dist/angular-20",
+ "index": "src/index.html",
+ "browser": "src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "tsconfig.app.json",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "public"
+ }
+ ],
+ "styles": ["src/styles.css"],
+ "scripts": []
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kB",
+ "maximumError": "1MB"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "4kB",
+ "maximumError": "8kB"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular-devkit/build-angular:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "angular-20:build:production"
+ },
+ "development": {
+ "buildTarget": "angular-20:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "extract-i18n": {
+ "builder": "@angular-devkit/build-angular:extract-i18n"
+ },
+ "test": {
+ "builder": "@angular-devkit/build-angular:karma",
+ "options": {
+ "polyfills": ["zone.js", "zone.js/testing"],
+ "tsConfig": "tsconfig.spec.json",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "public"
+ }
+ ],
+ "styles": ["src/styles.css"],
+ "scripts": []
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/package.json b/dev-packages/e2e-tests/test-applications/angular-20/package.json
new file mode 100644
index 000000000000..a43fcaf412b5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "angular-20",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "dev": "ng serve",
+ "proxy": "node start-event-proxy.mjs",
+ "preview": "http-server dist/angular-20/browser --port 8080",
+ "build": "ng build",
+ "watch": "ng build --watch --configuration development",
+ "test": "playwright test",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "playwright test",
+ "clean": "npx rimraf .angular node_modules pnpm-lock.yaml dist"
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/animations": "^20.0.0-rc.2",
+ "@angular/common": "^20.0.0-rc.2",
+ "@angular/compiler": "^20.0.0-rc.2",
+ "@angular/core": "^20.0.0-rc.2",
+ "@angular/forms": "^20.0.0-rc.2",
+ "@angular/platform-browser": "^20.0.0-rc.2",
+ "@angular/platform-browser-dynamic": "^20.0.0-rc.2",
+ "@angular/router": "^20.0.0-rc.2",
+ "@sentry/angular": "* || latest",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "zone.js": "~0.15.0"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "^20.0.0-rc.2",
+ "@angular/cli": "^20.0.0-rc.2",
+ "@angular/compiler-cli": "^20.0.0-rc.2",
+ "@playwright/test": "~1.50.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@types/jasmine": "~5.1.0",
+ "http-server": "^14.1.1",
+ "jasmine-core": "~5.4.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.8.3"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/angular-20/playwright.config.mjs
new file mode 100644
index 000000000000..0845325879c9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/playwright.config.mjs
@@ -0,0 +1,8 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm preview`,
+ port: 8080,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/public/favicon.ico b/dev-packages/e2e-tests/test-applications/angular-20/public/favicon.ico
new file mode 100644
index 000000000000..57614f9c9675
Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/angular-20/public/favicon.ico differ
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/src/app/app.component.ts b/dev-packages/e2e-tests/test-applications/angular-20/src/app/app.component.ts
new file mode 100644
index 000000000000..e912fcc99b04
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/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-20';
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/src/app/app.config.ts b/dev-packages/e2e-tests/test-applications/angular-20/src/app/app.config.ts
new file mode 100644
index 000000000000..f5cc30f3615b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/src/app/app.config.ts
@@ -0,0 +1,29 @@
+import {
+ ApplicationConfig,
+ ErrorHandler,
+ inject,
+ provideAppInitializer,
+ provideZoneChangeDetection,
+} 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: [
+ provideZoneChangeDetection({ eventCoalescing: true }),
+ provideRouter(routes),
+ {
+ provide: ErrorHandler,
+ useValue: createErrorHandler(),
+ },
+ {
+ provide: TraceService,
+ deps: [Router],
+ },
+ provideAppInitializer(() => {
+ inject(TraceService);
+ }),
+ ],
+};
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/src/app/app.routes.ts b/dev-packages/e2e-tests/test-applications/angular-20/src/app/app.routes.ts
new file mode 100644
index 000000000000..24bf8b769051
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/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-20/src/app/cancel-guard.guard.ts b/dev-packages/e2e-tests/test-applications/angular-20/src/app/cancel-guard.guard.ts
new file mode 100644
index 000000000000..16ec4a2ab164
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/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-20/src/app/cancel/cancel.components.ts b/dev-packages/e2e-tests/test-applications/angular-20/src/app/cancel/cancel.components.ts
new file mode 100644
index 000000000000..b6ee1876e035
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/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-20/src/app/component-tracking/component-tracking.components.ts b/dev-packages/e2e-tests/test-applications/angular-20/src/app/component-tracking/component-tracking.components.ts
new file mode 100644
index 000000000000..a82e5b1acce6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/src/app/component-tracking/component-tracking.components.ts
@@ -0,0 +1,21 @@
+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-component-tracking',
+ 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-20/src/app/home/home.component.ts b/dev-packages/e2e-tests/test-applications/angular-20/src/app/home/home.component.ts
new file mode 100644
index 000000000000..033174fb0d8a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/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 20 E2E test app
+
+
+
+ `,
+})
+export class HomeComponent {
+ throwError() {
+ throw new Error('Error thrown from Angular 20 E2E test app');
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/src/app/sample-component/sample-component.components.ts b/dev-packages/e2e-tests/test-applications/angular-20/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-20/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-20/src/app/user/user.component.ts b/dev-packages/e2e-tests/test-applications/angular-20/src/app/user/user.component.ts
new file mode 100644
index 000000000000..db02568d395f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/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-20/src/index.html b/dev-packages/e2e-tests/test-applications/angular-20/src/index.html
new file mode 100644
index 000000000000..0f546ff0114e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Angular 20
+
+
+
+
+
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/src/main.ts b/dev-packages/e2e-tests/test-applications/angular-20/src/main.ts
new file mode 100644
index 000000000000..a0b841afc333
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/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({
+ // Cannot use process.env here, so we hardcode the DSN
+ dsn: 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576',
+ tracesSampleRate: 1.0,
+ integrations: [Sentry.browserTracingIntegration({})],
+ tunnel: `http://localhost:3031/`, // proxy server
+});
+
+bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err));
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/src/styles.css b/dev-packages/e2e-tests/test-applications/angular-20/src/styles.css
new file mode 100644
index 000000000000..90d4ee0072ce
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/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-20/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/angular-20/start-event-proxy.mjs
new file mode 100644
index 000000000000..58559fb2f8b8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'angular-20',
+});
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/angular-20/tests/errors.test.ts
new file mode 100644
index 000000000000..98ae26e195cc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/tests/errors.test.ts
@@ -0,0 +1,65 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+
+test('sends an error', async ({ page }) => {
+ const errorPromise = waitForError('angular-20', 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 20 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-20', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const errorPromise = waitForError('angular-20', 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-20/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-20/tests/performance.test.ts
new file mode 100644
index 000000000000..f790cb10d180
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/tests/performance.test.ts
@@ -0,0 +1,326 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+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-20', 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-20', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('angular-20', 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-20', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('angular-20', 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-20', 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-20', 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-20', 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 span with the component name as span name on ngOnInit', async ({ page }) => {
+ const navigationTxnPromise = waitForTransaction('angular-20', 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 traceDirectiveSpans = navigationTxn.spans?.filter(
+ span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_directive',
+ );
+
+ expect(traceDirectiveSpans).toHaveLength(2);
+ expect(traceDirectiveSpans).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive',
+ },
+ description: '', // custom component name passed to trace directive
+ op: 'ui.angular.init',
+ origin: 'auto.ui.angular.trace_directive',
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ }),
+ expect.objectContaining({
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive',
+ },
+ description: '', // fallback selector name
+ 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-20', 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-20', 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-20', 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-20/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/angular-20/tsconfig.app.json
new file mode 100644
index 000000000000..8886e903f8d0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/tsconfig.app.json
@@ -0,0 +1,11 @@
+/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
+/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
+{
+ "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-20/tsconfig.json b/dev-packages/e2e-tests/test-applications/angular-20/tsconfig.json
new file mode 100644
index 000000000000..5525117c6744
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/tsconfig.json
@@ -0,0 +1,27 @@
+/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
+/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "experimentalDecorators": true,
+ "moduleResolution": "bundler",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022"
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/angular-20/tsconfig.spec.json b/dev-packages/e2e-tests/test-applications/angular-20/tsconfig.spec.json
new file mode 100644
index 000000000000..e00e30e6d4fb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/angular-20/tsconfig.spec.json
@@ -0,0 +1,10 @@
+/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
+/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/spec",
+ "types": ["jasmine"]
+ },
+ "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
+}