diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index fd19c79c..95804928 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -74,7 +74,8 @@ jobs:
timeout-minutes: 10
strategy:
matrix:
- wizard: [Nuxt-3, Nuxt-4, NextJS, Remix, Sveltekit]
+ wizard:
+ [Angular-17, Angular-19, Nuxt-3, Nuxt-4, NextJS, Remix, Sveltekit]
env:
SENTRY_TEST_AUTH_TOKEN: ${{ secrets.E2E_TEST_SENTRY_AUTH_TOKEN }}
SENTRY_TEST_ORG: 'sentry-javascript-sdks'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b888b3ea..c3d1fda7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
- feat(nuxt): Add `import-in-the-middle` install step when using pnpm ([#727](https://github.com/getsentry/sentry-wizard/pull/727))
- fix(nuxt): Remove unused parameter in sentry-example-api template ([#734](https://github.com/getsentry/sentry-wizard/pull/734))
+- feat: Add Angular Wizard ([#741](https://github.com/getsentry/sentry-wizard/pull/741))
- fix(nuxt): Remove option to downgrade override nitropack ([#744](https://github.com/getsentry/sentry-wizard/pull/744))
## 3.36.0
@@ -16,7 +17,7 @@
- feat: Pin JS SDK versions to v8 (#712)
- Remove enableTracing for Cocoa ([#715](https://github.com/getsentry/sentry-wizard/pull/715))
- feat(nuxt): Add nuxt wizard ([#719](https://github.com/getsentry/sentry-wizard/pull/719))
-
+
Set up the Sentry Nuxt SDK in your app with one command:
```sh
diff --git a/e2e-tests/test-applications/angular-17-test-app/.gitignore b/e2e-tests/test-applications/angular-17-test-app/.gitignore
new file mode 100644
index 00000000..cc7b1413
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/.gitignore
@@ -0,0 +1,42 @@
+# 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
diff --git a/e2e-tests/test-applications/angular-17-test-app/angular.json b/e2e-tests/test-applications/angular-17-test-app/angular.json
new file mode 100644
index 00000000..09ab967a
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/angular.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "angular-test-app": {
+ "projectType": "application",
+ "schematics": {},
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:application",
+ "options": {
+ "outputPath": "dist/angular-test-app",
+ "index": "src/index.html",
+ "browser": "src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "tsconfig.app.json",
+ "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-test-app:build:production"
+ },
+ "development": {
+ "buildTarget": "angular-test-app:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "extract-i18n": {
+ "builder": "@angular-devkit/build-angular:extract-i18n",
+ "options": {
+ "buildTarget": "angular-test-app:build"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/e2e-tests/test-applications/angular-17-test-app/package.json b/e2e-tests/test-applications/angular-17-test-app/package.json
new file mode 100644
index 00000000..3dbe7b91
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "angular-17-test-app",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "dev": "ng build --watch --configuration development"
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/animations": "^17.3.0",
+ "@angular/common": "^17.3.0",
+ "@angular/compiler": "^17.3.0",
+ "@angular/core": "^17.3.0",
+ "@angular/forms": "^17.3.0",
+ "@angular/platform-browser": "^17.3.0",
+ "@angular/platform-browser-dynamic": "^17.3.0",
+ "@angular/router": "^17.3.0",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "zone.js": "~0.14.3"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "^17.3.11",
+ "@angular/cli": "^17.3.11",
+ "@angular/compiler-cli": "^17.3.0",
+ "@types/jasmine": "~5.1.0",
+ "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.2"
+ }
+}
diff --git a/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.html b/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.html
new file mode 100644
index 00000000..6ad6ff44
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.html
@@ -0,0 +1,8 @@
+
+
+
Hello, {{ title }}
+
Congratulations! Your app is running. 🎉
+
+
+
+
diff --git a/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.ts b/e2e-tests/test-applications/angular-17-test-app/src/app/app.component.ts
new file mode 100644
index 00000000..99539624
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/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],
+ templateUrl: './app.component.html',
+})
+export class AppComponent {
+ title = 'angular-test-app';
+}
diff --git a/e2e-tests/test-applications/angular-17-test-app/src/app/app.config.ts b/e2e-tests/test-applications/angular-17-test-app/src/app/app.config.ts
new file mode 100644
index 00000000..6c6ef603
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/src/app/app.config.ts
@@ -0,0 +1,8 @@
+import { ApplicationConfig } from '@angular/core';
+import { provideRouter } from '@angular/router';
+
+import { routes } from './app.routes';
+
+export const appConfig: ApplicationConfig = {
+ providers: [provideRouter(routes)]
+};
diff --git a/e2e-tests/test-applications/angular-17-test-app/src/app/app.routes.ts b/e2e-tests/test-applications/angular-17-test-app/src/app/app.routes.ts
new file mode 100644
index 00000000..dc39edb5
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/src/app/app.routes.ts
@@ -0,0 +1,3 @@
+import { Routes } from '@angular/router';
+
+export const routes: Routes = [];
diff --git a/e2e-tests/test-applications/angular-17-test-app/src/index.html b/e2e-tests/test-applications/angular-17-test-app/src/index.html
new file mode 100644
index 00000000..2df4d484
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/src/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ AngularTestApp
+
+
+
+
+
+
+
diff --git a/e2e-tests/test-applications/angular-17-test-app/src/main.ts b/e2e-tests/test-applications/angular-17-test-app/src/main.ts
new file mode 100644
index 00000000..35b00f34
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/src/main.ts
@@ -0,0 +1,6 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { appConfig } from './app/app.config';
+import { AppComponent } from './app/app.component';
+
+bootstrapApplication(AppComponent, appConfig)
+ .catch((err) => console.error(err));
diff --git a/e2e-tests/test-applications/angular-17-test-app/tsconfig.app.json b/e2e-tests/test-applications/angular-17-test-app/tsconfig.app.json
new file mode 100644
index 00000000..374cc9d2
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/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/e2e-tests/test-applications/angular-17-test-app/tsconfig.json b/e2e-tests/test-applications/angular-17-test-app/tsconfig.json
new file mode 100644
index 00000000..eb49734a
--- /dev/null
+++ b/e2e-tests/test-applications/angular-17-test-app/tsconfig.json
@@ -0,0 +1,32 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "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/e2e-tests/test-applications/angular-19-test-app/.gitignore b/e2e-tests/test-applications/angular-19-test-app/.gitignore
new file mode 100644
index 00000000..cc7b1413
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/.gitignore
@@ -0,0 +1,42 @@
+# 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
diff --git a/e2e-tests/test-applications/angular-19-test-app/angular.json b/e2e-tests/test-applications/angular-19-test-app/angular.json
new file mode 100644
index 00000000..948d89ee
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/angular.json
@@ -0,0 +1,85 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "angular-19-test-app": {
+ "projectType": "application",
+ "schematics": {},
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:application",
+ "options": {
+ "outputPath": "dist/angular-19-test-app",
+ "index": "src/index.html",
+ "browser": "src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "tsconfig.app.json",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "public"
+ }
+ ],
+ "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-19-test-app:build:production"
+ },
+ "development": {
+ "buildTarget": "angular-19-test-app: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"
+ }
+ ],
+ "scripts": []
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/e2e-tests/test-applications/angular-19-test-app/package.json b/e2e-tests/test-applications/angular-19-test-app/package.json
new file mode 100644
index 00000000..cd80f35f
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "angular-19-test-app",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "dev": "ng build --watch --configuration development"
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/animations": "^19.0.0",
+ "@angular/common": "^19.0.0",
+ "@angular/compiler": "^19.0.0",
+ "@angular/core": "^19.0.0",
+ "@angular/forms": "^19.0.0",
+ "@angular/platform-browser": "^19.0.0",
+ "@angular/platform-browser-dynamic": "^19.0.0",
+ "@angular/router": "^19.0.0",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "zone.js": "~0.15.0"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "^19.0.5",
+ "@angular/cli": "^19.0.5",
+ "@angular/compiler-cli": "^19.0.0",
+ "@types/jasmine": "~5.1.0",
+ "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.6.2"
+ }
+}
diff --git a/e2e-tests/test-applications/angular-19-test-app/src/app/app.component.html b/e2e-tests/test-applications/angular-19-test-app/src/app/app.component.html
new file mode 100644
index 00000000..4e172ec3
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/src/app/app.component.html
@@ -0,0 +1,10 @@
+
+
+
+
Hello, {{ title }}
+
Congratulations! Your app is running. 🎉
+
+
+
+
+
diff --git a/e2e-tests/test-applications/angular-19-test-app/src/app/app.component.ts b/e2e-tests/test-applications/angular-19-test-app/src/app/app.component.ts
new file mode 100644
index 00000000..86fc9af8
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/src/app/app.component.ts
@@ -0,0 +1,11 @@
+import { Component } from '@angular/core';
+import { RouterOutlet } from '@angular/router';
+
+@Component({
+ selector: 'app-root',
+ imports: [RouterOutlet],
+ templateUrl: './app.component.html',
+})
+export class AppComponent {
+ title = 'angular-19-test-app';
+}
diff --git a/e2e-tests/test-applications/angular-19-test-app/src/app/app.config.ts b/e2e-tests/test-applications/angular-19-test-app/src/app/app.config.ts
new file mode 100644
index 00000000..a1e7d6f8
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/src/app/app.config.ts
@@ -0,0 +1,8 @@
+import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
+import { provideRouter } from '@angular/router';
+
+import { routes } from './app.routes';
+
+export const appConfig: ApplicationConfig = {
+ providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
+};
diff --git a/e2e-tests/test-applications/angular-19-test-app/src/app/app.routes.ts b/e2e-tests/test-applications/angular-19-test-app/src/app/app.routes.ts
new file mode 100644
index 00000000..dc39edb5
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/src/app/app.routes.ts
@@ -0,0 +1,3 @@
+import { Routes } from '@angular/router';
+
+export const routes: Routes = [];
diff --git a/e2e-tests/test-applications/angular-19-test-app/src/index.html b/e2e-tests/test-applications/angular-19-test-app/src/index.html
new file mode 100644
index 00000000..f3bf4c8f
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/src/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Angular19TestApp
+
+
+
+
+
+
+
diff --git a/e2e-tests/test-applications/angular-19-test-app/src/main.ts b/e2e-tests/test-applications/angular-19-test-app/src/main.ts
new file mode 100644
index 00000000..35b00f34
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/src/main.ts
@@ -0,0 +1,6 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { appConfig } from './app/app.config';
+import { AppComponent } from './app/app.component';
+
+bootstrapApplication(AppComponent, appConfig)
+ .catch((err) => console.error(err));
diff --git a/e2e-tests/test-applications/angular-19-test-app/tsconfig.app.json b/e2e-tests/test-applications/angular-19-test-app/tsconfig.app.json
new file mode 100644
index 00000000..3775b37e
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/tsconfig.app.json
@@ -0,0 +1,15 @@
+/* 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/e2e-tests/test-applications/angular-19-test-app/tsconfig.json b/e2e-tests/test-applications/angular-19-test-app/tsconfig.json
new file mode 100644
index 00000000..5525117c
--- /dev/null
+++ b/e2e-tests/test-applications/angular-19-test-app/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/e2e-tests/tests/angular-17.test.ts b/e2e-tests/tests/angular-17.test.ts
new file mode 100644
index 00000000..7022645f
--- /dev/null
+++ b/e2e-tests/tests/angular-17.test.ts
@@ -0,0 +1,170 @@
+/* eslint-disable jest/expect-expect */
+import { Integration } from "../../lib/Constants";
+import { checkFileContents, checkFileExists, checkIfBuilds, checkIfRunsOnDevMode, checkIfRunsOnProdMode, checkPackageJson, cleanupGit, KEYS, revertLocalChanges, startWizardInstance } from "../utils";
+import * as path from 'path';
+import { TEST_ARGS } from "../utils";
+
+async function runWizardOnAngularProject(projectDir: string, integration: Integration) {
+ const wizardInstance = startWizardInstance(integration, projectDir);
+ const packageManagerPrompted = await wizardInstance.waitForOutput(
+ 'Please select your package manager.',
+ );
+
+ const tracingOptionPrompted =
+ packageManagerPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ // Selecting `yarn` as the package manager
+ [KEYS.DOWN, KEYS.ENTER],
+ // "Do you want to enable Tracing", sometimes doesn't work as `Tracing` can be printed in bold.
+ 'to track the performance of your application?',
+ {
+ timeout: 240_000,
+ optional: true,
+ },
+ ));
+
+ const replayOptionPrompted =
+ tracingOptionPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.ENTER],
+ // "Do you want to enable Sentry Session Replay", sometimes doesn't work as `Sentry Session Replay` can be printed in bold.
+ 'to get a video-like reproduction of errors during a user session?',
+ ));
+
+ const sourcemapsPrompted = replayOptionPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ // The first choice here is Angular
+ [KEYS.ENTER],
+ 'Where are your build artifacts located?',
+ ));
+
+
+ const sourcemapsConfigured = sourcemapsPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ ["./dist", KEYS.ENTER],
+ 'Added a sentry:sourcemaps script to your package.json.',
+ ), {
+ optional: true,
+ });
+
+ const buildScriptPrompted = sourcemapsConfigured &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.ENTER],
+ 'Do you want to automatically run the sentry:sourcemaps script after each production build?',
+ ));
+
+ const defaultBuildCommandPrompted = buildScriptPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.ENTER],
+ 'Is yarn build your production build command?',
+ ));
+
+ const ciCdPrompted = defaultBuildCommandPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.ENTER],
+ 'Are you using a CI/CD tool to build and deploy your application?',
+ ));
+
+ ciCdPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.DOWN, KEYS.ENTER],
+ 'Sentry has been successfully configured for your Angular project',
+ ));
+
+ wizardInstance.kill();
+};
+
+function checkAngularProject(projectDir: string, integration: Integration) {
+ test('package.json is updated correctly', () => {
+ checkPackageJson(projectDir, integration);
+
+ const packageJsonFile = path.resolve(projectDir, 'package.json');
+ checkFileContents(packageJsonFile, [
+ `"sentry:sourcemaps": "sentry-cli sourcemaps inject --org ${TEST_ARGS.ORG_SLUG} --project ${TEST_ARGS.PROJECT_SLUG} ./dist && sentry-cli sourcemaps upload --org ${TEST_ARGS.ORG_SLUG} --project ${TEST_ARGS.PROJECT_SLUG} ./dist"`,
+ `"build": "ng build && yarn sentry:sourcemaps"`,
+ ]);
+ })
+
+ test('Sentry is correctly injected into Angular app config', () => {
+ const appConfigFile = path.resolve(projectDir, 'src/main.ts');
+ checkFileExists(appConfigFile);
+
+ checkFileContents(appConfigFile, [
+ `import * as Sentry from "@sentry/angular"`,
+ 'Sentry.init({',
+ TEST_ARGS.PROJECT_DSN,
+ 'Sentry.browserTracingIntegration()',
+ 'Sentry.replayIntegration()',
+ 'tracesSampleRate: 1',
+ 'replaysSessionSampleRate: 0.1',
+ 'replaysOnErrorSampleRate: 1',
+ ]);
+ });
+
+ test('Sentry is correctly injected into Angular app module', () => {
+ const appModuleFile = path.resolve(projectDir, 'src/app/app.config.ts');
+ checkFileExists(appModuleFile);
+
+ checkFileContents(appModuleFile, [
+ `import * as Sentry from "@sentry/angular"`,
+ `{
+ provide: ErrorHandler,
+ useValue: Sentry.createErrorHandler()
+ }`,
+ `{
+ provide: Sentry.TraceService,
+ deps: [Router]
+ }`,
+ `{
+ provide: APP_INITIALIZER,
+ useFactory: () => () => {},
+ deps: [Sentry.TraceService],
+ multi: true
+ }`,
+ ]);
+ });
+
+ test('angular.json is updated correctly', () => {
+ const angularJsonFile = path.resolve(projectDir, 'angular.json');
+ checkFileExists(angularJsonFile);
+
+ const angularJson = require(angularJsonFile);
+
+ for (const [, project] of Object.entries(angularJson.projects) as any) {
+ expect(project?.architect?.build?.configurations?.production?.sourceMap).toBe(true);
+ }
+ });
+
+ test('builds successfully', async () => {
+ await checkIfBuilds(projectDir, 'Application bundle generation complete.');
+ });
+
+ test('runs on prod mode correctly', async () => {
+ await checkIfRunsOnProdMode(projectDir, 'Application bundle generation complete.');
+ });
+
+ test('runs on dev mode correctly', async () => {
+ await checkIfRunsOnDevMode(projectDir, 'Application bundle generation complete.');
+ });
+};
+
+describe('Angular-17', () => {
+ describe('with empty project', () => {
+ const integration = Integration.angular;
+ const projectDir = path.resolve(
+ __dirname,
+ '../test-applications/angular-17-test-app',
+ );
+
+ beforeAll(async () => {
+ await runWizardOnAngularProject(projectDir, integration);
+ });
+
+ afterAll(() => {
+ revertLocalChanges(projectDir);
+ cleanupGit(projectDir);
+ });
+
+ checkAngularProject(projectDir, integration);
+ });
+});
diff --git a/e2e-tests/tests/angular-19.test.ts b/e2e-tests/tests/angular-19.test.ts
new file mode 100644
index 00000000..d7a4b041
--- /dev/null
+++ b/e2e-tests/tests/angular-19.test.ts
@@ -0,0 +1,167 @@
+/* eslint-disable jest/expect-expect */
+import { Integration } from "../../lib/Constants";
+import { checkFileContents, checkFileExists, checkIfBuilds, checkIfRunsOnDevMode, checkIfRunsOnProdMode, checkPackageJson, cleanupGit, KEYS, revertLocalChanges, startWizardInstance } from "../utils";
+import * as path from 'path';
+import { TEST_ARGS } from "../utils";
+
+async function runWizardOnAngularProject(projectDir: string, integration: Integration) {
+ const wizardInstance = startWizardInstance(integration, projectDir);
+ const packageManagerPrompted = await wizardInstance.waitForOutput(
+ 'Please select your package manager.',
+ );
+
+ const tracingOptionPrompted =
+ packageManagerPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ // Selecting `yarn` as the package manager
+ [KEYS.DOWN, KEYS.ENTER],
+ // "Do you want to enable Tracing", sometimes doesn't work as `Tracing` can be printed in bold.
+ 'to track the performance of your application?',
+ {
+ timeout: 240_000,
+ optional: true,
+ },
+ ));
+
+ const replayOptionPrompted =
+ tracingOptionPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.ENTER],
+ // "Do you want to enable Sentry Session Replay", sometimes doesn't work as `Sentry Session Replay` can be printed in bold.
+ 'to get a video-like reproduction of errors during a user session?',
+ ));
+
+ const sourcemapsPrompted = replayOptionPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ // The first choice here is Angular
+ [KEYS.ENTER],
+ 'Where are your build artifacts located?',
+ ));
+
+
+ const sourcemapsConfigured = sourcemapsPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ ["./dist", KEYS.ENTER],
+ 'Added a sentry:sourcemaps script to your package.json.',
+ ), {
+ optional: true,
+ });
+
+ const buildScriptPrompted = sourcemapsConfigured &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.ENTER],
+ 'Do you want to automatically run the sentry:sourcemaps script after each production build?',
+ ));
+
+ const defaultBuildCommandPrompted = buildScriptPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.ENTER],
+ 'Is yarn build your production build command?',
+ ));
+
+ const ciCdPrompted = defaultBuildCommandPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.ENTER],
+ 'Are you using a CI/CD tool to build and deploy your application?',
+ ));
+
+ ciCdPrompted &&
+ (await wizardInstance.sendStdinAndWaitForOutput(
+ [KEYS.DOWN, KEYS.ENTER],
+ 'Sentry has been successfully configured for your Angular project',
+ ));
+
+ wizardInstance.kill();
+};
+
+function checkAngularProject(projectDir: string, integration: Integration) {
+ test('package.json is updated correctly', () => {
+ checkPackageJson(projectDir, integration);
+
+ const packageJsonFile = path.resolve(projectDir, 'package.json');
+ checkFileContents(packageJsonFile, [
+ `"sentry:sourcemaps": "sentry-cli sourcemaps inject --org ${TEST_ARGS.ORG_SLUG} --project ${TEST_ARGS.PROJECT_SLUG} ./dist && sentry-cli sourcemaps upload --org ${TEST_ARGS.ORG_SLUG} --project ${TEST_ARGS.PROJECT_SLUG} ./dist"`,
+ `"build": "ng build && yarn sentry:sourcemaps"`,
+ ]);
+ })
+
+ test('Sentry is correctly injected into Angular app config', () => {
+ const appConfigFile = path.resolve(projectDir, 'src/main.ts');
+ checkFileExists(appConfigFile);
+
+ checkFileContents(appConfigFile, [
+ `import * as Sentry from "@sentry/angular"`,
+ 'Sentry.init({',
+ TEST_ARGS.PROJECT_DSN,
+ 'Sentry.browserTracingIntegration()',
+ 'Sentry.replayIntegration()',
+ 'tracesSampleRate: 1',
+ 'replaysSessionSampleRate: 0.1',
+ 'replaysOnErrorSampleRate: 1',
+ ]);
+ });
+
+ test('Sentry is correctly injected into Angular app module', () => {
+ const appModuleFile = path.resolve(projectDir, 'src/app/app.config.ts');
+ checkFileExists(appModuleFile);
+
+ checkFileContents(appModuleFile, [
+ `import * as Sentry from "@sentry/angular"`,
+ `{
+ provide: ErrorHandler,
+ useValue: Sentry.createErrorHandler()
+ }`,
+ `{
+ provide: Sentry.TraceService,
+ deps: [Router]
+ }`,
+ `provideAppInitializer(() => {
+ inject(Sentry.TraceService);
+ })`,
+ ]);
+ });
+
+ test('angular.json is updated correctly', () => {
+ const angularJsonFile = path.resolve(projectDir, 'angular.json');
+ checkFileExists(angularJsonFile);
+
+ const angularJson = require(angularJsonFile);
+
+ for (const [, project] of Object.entries(angularJson.projects) as any) {
+ expect(project?.architect?.build?.configurations?.production?.sourceMap).toBe(true);
+ }
+ });
+
+ test('builds successfully', async () => {
+ await checkIfBuilds(projectDir, 'Application bundle generation complete.');
+ });
+
+ test('runs on prod mode correctly', async () => {
+ await checkIfRunsOnProdMode(projectDir, 'Application bundle generation complete.');
+ });
+
+ test('runs on dev mode correctly', async () => {
+ await checkIfRunsOnDevMode(projectDir, 'Application bundle generation complete.');
+ });
+};
+
+describe('Angular-19', () => {
+ describe('with empty project', () => {
+ const integration = Integration.angular;
+ const projectDir = path.resolve(
+ __dirname,
+ '../test-applications/angular-19-test-app',
+ );
+
+ beforeAll(async () => {
+ await runWizardOnAngularProject(projectDir, integration);
+ });
+
+ afterAll(() => {
+ revertLocalChanges(projectDir);
+ cleanupGit(projectDir);
+ });
+
+ checkAngularProject(projectDir, integration);
+ });
+});
diff --git a/e2e-tests/tests/remix.test.ts b/e2e-tests/tests/remix.test.ts
index 8a282123..60616bf1 100644
--- a/e2e-tests/tests/remix.test.ts
+++ b/e2e-tests/tests/remix.test.ts
@@ -78,7 +78,6 @@ async function runWizardOnRemixProject(projectDir: string, integration: Integrat
'Please select your package manager.',
);
} else {
-
packageManagerPrompted = await wizardInstance.waitForOutput(
'Please select your package manager.',
);
diff --git a/lib/Constants.ts b/lib/Constants.ts
index 4350d9a0..09eb6a4b 100644
--- a/lib/Constants.ts
+++ b/lib/Constants.ts
@@ -4,6 +4,7 @@ export enum Integration {
ios = 'ios',
android = 'android',
cordova = 'cordova',
+ angular = 'angular',
electron = 'electron',
nextjs = 'nextjs',
nuxt = 'nuxt',
@@ -68,6 +69,8 @@ export function mapIntegrationToPlatform(type: string): string | undefined {
return 'react-native';
case Integration.cordova:
return 'cordova';
+ case Integration.angular:
+ return 'javascript-angular';
case Integration.electron:
return 'javascript-electron';
case Integration.nextjs:
diff --git a/src/angular/angular-wizard.ts b/src/angular/angular-wizard.ts
new file mode 100644
index 00000000..f7244524
--- /dev/null
+++ b/src/angular/angular-wizard.ts
@@ -0,0 +1,185 @@
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+
+// @ts-expect-error - clack is ESM and TS complains about that. It works though
+import clack from '@clack/prompts';
+
+import chalk from 'chalk';
+import type { WizardOptions } from '../utils/types';
+import { traceStep, withTelemetry } from '../telemetry';
+import {
+ abortIfCancelled,
+ confirmContinueIfNoOrDirtyGitRepo,
+ ensurePackageIsInstalled,
+ featureSelectionPrompt,
+ getOrAskForProjectData,
+ getPackageDotJson,
+ installPackage,
+ printWelcome,
+ runPrettierIfInstalled,
+} from '../utils/clack-utils';
+import { getPackageVersion, hasPackageInstalled } from '../utils/package-json';
+import { gte, minVersion, SemVer } from 'semver';
+import {
+ initalizeSentryOnApplicationEntry,
+ updateAppConfig,
+} from './sdk-setup';
+import { addSourcemapEntryToAngularJSON } from './codemods/sourcemaps';
+import { runSourcemapsWizard } from '../sourcemaps/sourcemaps-wizard';
+import * as Sentry from '@sentry/node';
+
+const MIN_SUPPORTED_ANGULAR_VERSION = '14.0.0';
+
+export async function runAngularWizard(options: WizardOptions): Promise {
+ return withTelemetry(
+ {
+ enabled: options.telemetryEnabled,
+ integration: 'angular',
+ wizardOptions: options,
+ },
+ () => runAngularWizardWithTelemetry(options),
+ );
+}
+
+async function runAngularWizardWithTelemetry(
+ options: WizardOptions,
+): Promise {
+ printWelcome({
+ wizardName: 'Sentry Angular Wizard',
+ promoCode: options.promoCode,
+ telemetryEnabled: options.telemetryEnabled,
+ });
+
+ await confirmContinueIfNoOrDirtyGitRepo();
+
+ const packageJson = await getPackageDotJson();
+
+ await ensurePackageIsInstalled(packageJson, '@angular/core', 'Angular');
+
+ let installedAngularVersion = getPackageVersion('@angular/core', packageJson);
+
+ if (!installedAngularVersion) {
+ clack.log.warn('Could not determine installed Angular version.');
+
+ installedAngularVersion = await abortIfCancelled(
+ clack.text({
+ message: 'Please enter the installed Angular version:',
+ validate(value) {
+ if (!value) {
+ return 'Please enter the installed Angular version.';
+ }
+
+ if (!minVersion(value)) {
+ return `Invalid Angular version provided: ${value}`;
+ }
+ },
+ }),
+ );
+ }
+
+ const installedMinVersion = minVersion(installedAngularVersion) as SemVer;
+
+ const isSupportedAngularVersion = gte(
+ installedMinVersion,
+ MIN_SUPPORTED_ANGULAR_VERSION,
+ );
+
+ if (!isSupportedAngularVersion) {
+ clack.log.warn(
+ `Angular version ${MIN_SUPPORTED_ANGULAR_VERSION} or higher is required.`,
+ );
+ clack.log.warn(
+ `Please refer to Sentry's version compatibility table for more information: ${chalk.underline(
+ 'https://docs.sentry.io/platforms/javascript/guides/angular/#angular-version-compatibility',
+ )}`,
+ );
+
+ return;
+ }
+
+ const { selectedProject, authToken, sentryUrl, selfHosted } =
+ await getOrAskForProjectData(options, 'javascript-angular');
+
+ const sdkAlreadyInstalled = hasPackageInstalled(
+ '@sentry/angular',
+ packageJson,
+ );
+
+ Sentry.setTag('sdk-already-installed', sdkAlreadyInstalled);
+
+ await installPackage({
+ packageName: '@sentry/angular@^8',
+ packageNameDisplayLabel: '@sentry/angular',
+ alreadyInstalled: sdkAlreadyInstalled,
+ });
+
+ const dsn = selectedProject.keys[0].dsn.public;
+
+ const selectedFeatures = await featureSelectionPrompt([
+ {
+ id: 'performance',
+ prompt: `Do you want to enable ${chalk.bold(
+ 'Tracing',
+ )} to track the performance of your application?`,
+ enabledHint: 'recommended',
+ },
+ {
+ id: 'replay',
+ prompt: `Do you want to enable ${chalk.bold(
+ 'Sentry Session Replay',
+ )} to get a video-like reproduction of errors during a user session?`,
+ enabledHint: 'recommended, but increases bundle size',
+ },
+ ] as const);
+
+ await traceStep(
+ 'Initialize Sentry on Angular application entry point',
+ async () => {
+ await initalizeSentryOnApplicationEntry(dsn, selectedFeatures);
+ },
+ );
+
+ await traceStep('Update Angular project configuration', async () => {
+ await updateAppConfig(installedMinVersion, selectedFeatures.performance);
+ });
+
+ await traceStep('Setup for sourcemap uploads', async () => {
+ await addSourcemapEntryToAngularJSON();
+
+ if (!options.preSelectedProject) {
+ options.preSelectedProject = {
+ authToken,
+ selfHosted,
+ project: {
+ organization: {
+ id: selectedProject.organization.id,
+ name: selectedProject.organization.name,
+ slug: selectedProject.organization.slug,
+ },
+ id: selectedProject.id,
+ slug: selectedProject.slug,
+ keys: [
+ {
+ dsn: {
+ public: dsn,
+ },
+ },
+ ],
+ },
+ };
+
+ options.url = sentryUrl;
+ }
+
+ await runSourcemapsWizard(options, 'angular');
+ });
+
+ await traceStep('Run Prettier', async () => {
+ await runPrettierIfInstalled();
+ });
+
+ clack.outro(`
+ ${chalk.green(
+ 'Sentry has been successfully configured for your Angular project.',
+ )}`
+ );
+}
diff --git a/src/angular/codemods/app-config.ts b/src/angular/codemods/app-config.ts
new file mode 100644
index 00000000..c8fcabb9
--- /dev/null
+++ b/src/angular/codemods/app-config.ts
@@ -0,0 +1,234 @@
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+
+import type { ArrayExpression, Identifier, ObjectProperty } from '@babel/types';
+
+// @ts-expect-error - magicast is ESM and TS complains about that. It works though
+import type { ProxifiedModule } from 'magicast';
+import { gte, type SemVer } from 'semver';
+import * as recast from 'recast';
+
+export function updateAppConfigMod(
+ originalAppConfigMod: ProxifiedModule,
+ angularVersion: SemVer,
+ isTracingEnabled: boolean,
+): ProxifiedModule {
+ const isAboveAngularV19 = gte(angularVersion, '19.0.0');
+
+ addImports(originalAppConfigMod, isAboveAngularV19, isTracingEnabled);
+ addProviders(originalAppConfigMod, isAboveAngularV19, isTracingEnabled);
+
+ return originalAppConfigMod;
+}
+
+function addSentryImport(originalAppConfigMod: ProxifiedModule): void {
+ const imports = originalAppConfigMod.imports;
+ const hasSentryImport = imports.$items.some(
+ (item) => item.from === '@sentry/angular',
+ );
+
+ if (!hasSentryImport) {
+ imports.$add({
+ from: '@sentry/angular',
+ imported: '*',
+ local: 'Sentry',
+ });
+ }
+}
+
+function addErrorHandlerImport(
+ originalAppConfigMod: ProxifiedModule,
+): void {
+ const imports = originalAppConfigMod.imports;
+ const hasErrorHandler = imports.$items.some(
+ (item) => item.local === 'ErrorHandler' && item.from === '@angular/core',
+ );
+
+ if (!hasErrorHandler) {
+ imports.$add({
+ from: '@angular/core',
+ imported: 'ErrorHandler',
+ local: 'ErrorHandler',
+ });
+ }
+}
+
+function addRouterImport(originalAppConfigMod: ProxifiedModule): void {
+ const imports = originalAppConfigMod.imports;
+ const hasRouter = imports.$items.some(
+ (item) => item.local === 'Router' && item.from === '@angular/router',
+ );
+
+ if (!hasRouter) {
+ imports.$add({
+ from: '@angular/router',
+ imported: 'Router',
+ local: 'Router',
+ });
+ }
+}
+
+function addMissingImportsV19(
+ originalAppConfigMod: ProxifiedModule,
+): void {
+ const imports = originalAppConfigMod.imports;
+
+ const hasProvideAppInitializer = imports.$items.some(
+ (item) =>
+ item.local === 'provideAppInitializer' && item.from === '@angular/core',
+ );
+
+ if (!hasProvideAppInitializer) {
+ imports.$add({
+ from: '@angular/core',
+ imported: 'provideAppInitializer',
+ local: 'provideAppInitializer',
+ });
+ }
+
+ const hasInject = imports.$items.some(
+ (item) => item.local === 'inject' && item.from === '@angular/core',
+ );
+
+ if (!hasInject) {
+ imports.$add({
+ from: '@angular/core',
+ imported: 'inject',
+ local: 'inject',
+ });
+ }
+}
+
+function addAppInitializer(originalAppConfigMod: ProxifiedModule): void {
+ const imports = originalAppConfigMod.imports;
+
+ const hasAppInitializer = imports.$items.some(
+ (item) => item.local === 'APP_INITIALIZER' && item.from === '@angular/core',
+ );
+
+ if (!hasAppInitializer) {
+ imports.$add({
+ from: '@angular/core',
+ imported: 'APP_INITIALIZER',
+ local: 'APP_INITIALIZER',
+ });
+ }
+}
+
+function addImports(
+ originalAppConfigMod: ProxifiedModule,
+ isAboveAngularV19: boolean,
+ isTracingEnabled: boolean,
+): void {
+ addSentryImport(originalAppConfigMod);
+ addErrorHandlerImport(originalAppConfigMod);
+ addRouterImport(originalAppConfigMod);
+
+ if (isAboveAngularV19) {
+ addMissingImportsV19(originalAppConfigMod);
+ } else if (isTracingEnabled) {
+ addAppInitializer(originalAppConfigMod);
+ }
+}
+
+function addProviders(
+ originalAppConfigMod: ProxifiedModule,
+ isAboveAngularV19: boolean,
+ isTracingEnabled: boolean,
+): void {
+ const b = recast.types.builders;
+
+ recast.visit(originalAppConfigMod.exports.$ast, {
+ visitExportNamedDeclaration(path) {
+ // @ts-expect-error - declaration should always be present in this case
+ if (path.node.declaration.declarations[0].id.name === 'appConfig') {
+ const appConfigProps =
+ // @ts-expect-error - declaration should always be present in this case
+ path.node.declaration.declarations[0].init.properties;
+
+ const providers = appConfigProps.find(
+ (prop: ObjectProperty) =>
+ (prop.key as Identifier).name === 'providers',
+ ).value as ArrayExpression;
+
+ const errorHandlerObject = b.objectExpression([
+ b.objectProperty(
+ b.identifier('provide'),
+ b.identifier('ErrorHandler'),
+ ),
+ b.objectProperty(
+ b.identifier('useValue'),
+ b.identifier('Sentry.createErrorHandler()'),
+ ),
+ ]);
+
+ providers.elements.push(
+ // @ts-expect-error - errorHandlerObject is an objectExpression
+ errorHandlerObject,
+ );
+
+ if (isTracingEnabled) {
+ const traceServiceObject = b.objectExpression([
+ b.objectProperty(
+ b.identifier('provide'),
+ b.identifier('Sentry.TraceService'),
+ ),
+ b.objectProperty(
+ b.identifier('deps'),
+ b.arrayExpression([b.identifier('Router')]),
+ ),
+ ]);
+
+ // @ts-expect-error - traceServiceObject is an objectExpression
+ providers.elements.push(traceServiceObject);
+
+ if (isAboveAngularV19) {
+ const provideAppInitializerCall = b.callExpression(
+ b.identifier('provideAppInitializer'),
+ [
+ b.arrowFunctionExpression(
+ [],
+ b.blockStatement([
+ b.expressionStatement(
+ b.callExpression(b.identifier('inject'), [
+ b.identifier('Sentry.TraceService'),
+ ]),
+ ),
+ ]),
+ ),
+ ],
+ );
+
+ // @ts-expect-error - provideAppInitializerCall is an objectExpression
+ providers.elements.push(provideAppInitializerCall);
+ } else {
+ const provideAppInitializerObject = b.objectExpression([
+ b.objectProperty(
+ b.identifier('provide'),
+ b.identifier('APP_INITIALIZER'),
+ ),
+ b.objectProperty(
+ b.identifier('useFactory'),
+ b.arrowFunctionExpression(
+ [],
+ b.arrowFunctionExpression([], b.blockStatement([])),
+ ),
+ ),
+ b.objectProperty(
+ b.identifier('deps'),
+ b.arrayExpression([b.identifier('Sentry.TraceService')]),
+ ),
+ b.objectProperty(b.identifier('multi'), b.booleanLiteral(true)),
+ ]);
+
+ // @ts-expect-error - provideAppInitializerObject is an objectExpression
+ providers.elements.push(provideAppInitializerObject);
+ }
+ }
+ }
+
+ this.traverse(path);
+ },
+ });
+}
diff --git a/src/angular/codemods/main.ts b/src/angular/codemods/main.ts
new file mode 100644
index 00000000..896d6ad9
--- /dev/null
+++ b/src/angular/codemods/main.ts
@@ -0,0 +1,101 @@
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+
+import type { Program } from '@babel/types';
+
+// @ts-expect-error - magicast is ESM and TS complains about that. It works though
+import { builders, generateCode, type ProxifiedModule } from 'magicast';
+
+export function updateAppEntryMod(
+ originalAppModuleMod: ProxifiedModule,
+ dsn: string,
+ selectedFeatures: {
+ performance: boolean;
+ replay: boolean;
+ },
+): ProxifiedModule {
+ originalAppModuleMod.imports.$add({
+ from: '@sentry/angular',
+ imported: '*',
+ local: 'Sentry',
+ });
+
+ insertInitCall(originalAppModuleMod, dsn, selectedFeatures);
+
+ return originalAppModuleMod;
+}
+
+export function insertInitCall(
+ originalAppModuleMod: ProxifiedModule,
+ dsn: string,
+ selectedFeatures: {
+ performance: boolean;
+ replay: boolean;
+ },
+): void {
+ const initCallArgs = getInitCallArgs(dsn, selectedFeatures);
+ const initCall = builders.functionCall('Sentry.init', initCallArgs);
+ const originalAppModuleModAst = originalAppModuleMod.$ast as Program;
+
+ const initCallInsertionIndex = getAfterImportsInsertionIndex(
+ originalAppModuleModAst,
+ );
+
+ originalAppModuleModAst.body.splice(
+ initCallInsertionIndex,
+ 0,
+ // @ts-expect-error - string works here because the AST is proxified by magicast
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ generateCode(initCall).code,
+ );
+}
+
+export function getInitCallArgs(
+ dsn: string,
+ selectedFeatures: {
+ performance: boolean;
+ replay: boolean;
+ },
+): Record {
+ const initCallArgs = {
+ dsn,
+ } as Record;
+
+ if (selectedFeatures.replay || selectedFeatures.performance) {
+ initCallArgs.integrations = [];
+
+ if (selectedFeatures.performance) {
+ // @ts-expect-error - Adding Proxified AST node to the array
+ initCallArgs.integrations.push(
+ builders.functionCall('Sentry.browserTracingIntegration'),
+ );
+ initCallArgs.tracesSampleRate = 1.0;
+ }
+
+ if (selectedFeatures.replay) {
+ // @ts-expect-error - Adding Proxified AST node to the array
+ initCallArgs.integrations.push(
+ builders.functionCall('Sentry.replayIntegration'),
+ );
+
+ initCallArgs.replaysSessionSampleRate = 0.1;
+ initCallArgs.replaysOnErrorSampleRate = 1.0;
+ }
+ }
+
+ return initCallArgs;
+}
+
+/**
+ * We want to insert the handleError function just after all imports
+ */
+export function getAfterImportsInsertionIndex(
+ originalEntryServerModAST: Program,
+): number {
+ for (let x = originalEntryServerModAST.body.length - 1; x >= 0; x--) {
+ if (originalEntryServerModAST.body[x].type === 'ImportDeclaration') {
+ return x + 1;
+ }
+ }
+
+ return 0;
+}
diff --git a/src/angular/codemods/sourcemaps.ts b/src/angular/codemods/sourcemaps.ts
new file mode 100644
index 00000000..22450ca0
--- /dev/null
+++ b/src/angular/codemods/sourcemaps.ts
@@ -0,0 +1,51 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+
+// @ts-ignore - clack is ESM and TS complains about that. It works though
+import * as clack from '@clack/prompts';
+import * as path from 'path';
+import * as fs from 'fs';
+import { configureAngularSourcemapGenerationFlow } from '../../sourcemaps/tools/angular';
+
+export async function addSourcemapEntryToAngularJSON(): Promise {
+ const angularJsonPath = path.join(process.cwd(), 'angular.json');
+ const angularJSONFile = fs.readFileSync(angularJsonPath, 'utf-8');
+ const angularJson = JSON.parse(angularJSONFile);
+
+ if (!angularJson) {
+ await configureAngularSourcemapGenerationFlow();
+ }
+
+ const projects = Object.keys(angularJson.projects as Record);
+
+ if (!projects.length) {
+ await configureAngularSourcemapGenerationFlow();
+ }
+
+ // Emit sourcemaps from all projects in angular.json
+ for (const project of projects) {
+ const projectConfig = angularJson.projects[project];
+
+ if (!projectConfig.architect) {
+ projectConfig.architect = {};
+ }
+
+ if (!projectConfig.architect.build) {
+ projectConfig.architect.build = {};
+ }
+
+ if (!projectConfig.architect.build.configurations) {
+ projectConfig.architect.build.configurations = {};
+ }
+
+ projectConfig.architect.build.configurations.production = {
+ sourceMap: true,
+ };
+ }
+
+ fs.writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2));
+
+ clack.log.info(
+ 'Added sourcemap configuration to angular.json for all projects',
+ );
+}
diff --git a/src/angular/sdk-setup.ts b/src/angular/sdk-setup.ts
new file mode 100644
index 00000000..9146648f
--- /dev/null
+++ b/src/angular/sdk-setup.ts
@@ -0,0 +1,107 @@
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+
+// @ts-expect-error - magicast is ESM and TS complains about that. It works though
+import { loadFile, type ProxifiedModule, writeFile } from 'magicast';
+
+import * as path from 'path';
+
+// @ts-expect-error - clack is ESM and TS complains about that. It works though
+import clack from '@clack/prompts';
+import chalk from 'chalk';
+import { updateAppEntryMod } from './codemods/main';
+import { updateAppConfigMod } from './codemods/app-config';
+import type { SemVer } from 'semver';
+
+export function hasSentryContent(
+ fileName: string,
+ fileContent: string,
+ expectedContent = '@sentry/angular',
+): boolean {
+ const includesContent = fileContent.includes(expectedContent);
+
+ if (includesContent) {
+ clack.log.warn(
+ `File ${chalk.cyan(
+ path.basename(fileName),
+ )} already contains ${expectedContent}.
+Skipping adding Sentry functionality to ${chalk.cyan(
+ path.basename(fileName),
+ )}.`,
+ );
+ }
+
+ return includesContent;
+}
+
+export async function initalizeSentryOnApplicationEntry(
+ dsn: string,
+ selectedFeatures: {
+ performance: boolean;
+ replay: boolean;
+ },
+): Promise {
+ const appEntryFilename = 'main.ts';
+ const appEntryPath = path.join(process.cwd(), 'src', appEntryFilename);
+
+ const originalAppEntry = await loadFile(appEntryPath);
+
+ if (hasSentryContent(appEntryPath, originalAppEntry.$code)) {
+ return;
+ }
+
+ const updatedAppEntryMod = updateAppEntryMod(
+ originalAppEntry,
+ dsn,
+ selectedFeatures,
+ );
+
+ await writeFile(updatedAppEntryMod.$ast, appEntryPath);
+
+ clack.log.success(
+ `Successfully initialized Sentry on your app module ${chalk.cyan(
+ appEntryFilename,
+ )}`,
+ );
+}
+
+export async function updateAppConfig(
+ angularVersion: SemVer,
+ isTracingEnabled: boolean,
+): Promise {
+ const appConfigFilename = 'app.config.ts';
+ const appConfigPath = path.join(
+ process.cwd(),
+ 'src',
+ 'app',
+ appConfigFilename,
+ );
+
+ const appConfig = await loadFile(appConfigPath);
+
+ if (hasSentryContent(appConfigPath, appConfig.$code)) {
+ return;
+ }
+ let updatedAppConfigMod: ProxifiedModule;
+
+ try {
+ updatedAppConfigMod = updateAppConfigMod(
+ appConfig,
+ angularVersion,
+ isTracingEnabled,
+ );
+
+ await writeFile(updatedAppConfigMod.$ast, appConfigPath);
+ } catch (error) {
+ clack.log.error(
+ `Failed to update your app config ${chalk.cyan(appConfigFilename)}`,
+ );
+
+ clack.log.error(error);
+
+ return;
+ }
+
+ clack.log.success(
+ `Successfully updated your app config ${chalk.cyan(appConfigFilename)}`,
+ );
+}
diff --git a/src/run.ts b/src/run.ts
index a0dc6192..0eaae784 100644
--- a/src/run.ts
+++ b/src/run.ts
@@ -6,6 +6,7 @@ import { runReactNativeWizard } from './react-native/react-native-wizard';
import { run as legacyRun } from '../lib/Setup';
import type { PreselectedProject, WizardOptions } from './utils/types';
import { runAndroidWizard } from './android/android-wizard';
+import { runAngularWizard } from './angular/angular-wizard';
import { runAppleWizard } from './apple/apple-wizard';
import { runNextjsWizard } from './nextjs/nextjs-wizard';
import { runNuxtWizard } from './nuxt/nuxt-wizard';
@@ -17,6 +18,7 @@ import type { Platform } from '../lib/Constants';
import type { PackageDotJson } from './utils/package-json';
type WizardIntegration =
+ | 'angular'
| 'reactNative'
| 'ios'
| 'android'
@@ -101,6 +103,7 @@ export async function run(argv: Args) {
options: [
{ value: 'reactNative', label: 'React Native' },
{ value: 'ios', label: 'iOS' },
+ { value: 'angular', label: 'Angular' },
{ value: 'android', label: 'Android' },
{ value: 'cordova', label: 'Cordova' },
{ value: 'electron', label: 'Electron' },
@@ -147,6 +150,10 @@ export async function run(argv: Args) {
await runAndroidWizard(wizardOptions);
break;
+ case 'angular':
+ await runAngularWizard(wizardOptions);
+ break;
+
case 'nextjs':
await runNextjsWizard(wizardOptions);
break;
diff --git a/src/sourcemaps/sourcemaps-wizard.ts b/src/sourcemaps/sourcemaps-wizard.ts
index a1ca2047..353b4fdc 100644
--- a/src/sourcemaps/sourcemaps-wizard.ts
+++ b/src/sourcemaps/sourcemaps-wizard.ts
@@ -35,6 +35,7 @@ import { getIssueStreamUrl } from '../utils/url';
export async function runSourcemapsWizard(
options: WizardOptions,
+ preSelectedTool?: SupportedTools,
): Promise {
return withTelemetry(
{
@@ -42,26 +43,29 @@ export async function runSourcemapsWizard(
integration: 'sourcemaps',
wizardOptions: options,
},
- () => runSourcemapsWizardWithTelemetry(options),
+ () => runSourcemapsWizardWithTelemetry(options, preSelectedTool),
);
}
async function runSourcemapsWizardWithTelemetry(
options: WizardOptions,
+ preSelectedTool?: SupportedTools,
): Promise {
- printWelcome({
- wizardName: 'Sentry Source Maps Upload Configuration Wizard',
- message: `This wizard will help you upload source maps to Sentry as part of your build.
+ if (!preSelectedTool) {
+ printWelcome({
+ wizardName: 'Sentry Source Maps Upload Configuration Wizard',
+ message: `This wizard will help you upload source maps to Sentry as part of your build.
Thank you for using Sentry :)${
- options.telemetryEnabled
- ? `
+ options.telemetryEnabled
+ ? `
(This setup wizard sends telemetry data and crash reports to Sentry.
You can turn this off by running the wizard with the '--disable-telemetry' flag.)`
- : ''
- }`,
- promoCode: options.promoCode,
- });
+ : ''
+ }`,
+ promoCode: options.promoCode,
+ });
+ }
const moreSuitableWizard = await traceStep(
'check-framework-wizard',
@@ -72,7 +76,9 @@ You can turn this off by running the wizard with the '--disable-telemetry' flag.
return;
}
- await confirmContinueIfNoOrDirtyGitRepo();
+ if (!preSelectedTool) {
+ await confirmContinueIfNoOrDirtyGitRepo();
+ }
await traceStep('check-sdk-version', ensureMinimumSdkVersionIsInstalled);
@@ -88,7 +94,8 @@ You can turn this off by running the wizard with the '--disable-telemetry' flag.
},
};
- const selectedTool = await traceStep('select-tool', askForUsedBundlerTool);
+ const selectedTool =
+ preSelectedTool || (await traceStep('select-tool', askForUsedBundlerTool));
Sentry.setTag('selected-tool', selectedTool);
@@ -111,6 +118,7 @@ You can turn this off by running the wizard with the '--disable-telemetry' flag.
authToken,
},
wizardOptionsWithPreSelectedProject,
+ preSelectedTool,
),
);
@@ -194,11 +202,12 @@ async function askForUsedBundlerTool(): Promise {
}
async function startToolSetupFlow(
- selctedTool: SupportedTools,
+ selectedTool: SupportedTools,
options: SourceMapUploadToolConfigurationOptions,
wizardOptions: WizardOptions,
+ preSelectedTool?: SupportedTools,
): Promise {
- switch (selctedTool) {
+ switch (selectedTool) {
case 'webpack':
await configureWebPackPlugin(options);
break;
@@ -220,7 +229,11 @@ async function startToolSetupFlow(
case 'angular':
await configureSentryCLI(
options,
- configureAngularSourcemapGenerationFlow,
+ // Angular wizard handles the angular.json setup itself
+ !preSelectedTool || preSelectedTool !== 'angular'
+ ? configureAngularSourcemapGenerationFlow
+ : // Not leaving this as undefined to avoid default. This is expected to be a no-op.
+ async () => Promise.resolve(),
);
break;
case 'nextjs':
diff --git a/src/sourcemaps/tools/angular.ts b/src/sourcemaps/tools/angular.ts
index ab31e93f..fc8c674b 100644
--- a/src/sourcemaps/tools/angular.ts
+++ b/src/sourcemaps/tools/angular.ts
@@ -3,7 +3,7 @@ import clack from '@clack/prompts';
import chalk from 'chalk';
import { abortIfCancelled } from '../../utils/clack-utils';
-const angularJsonTemplate = chalk.gray(`{
+export const angularJsonTemplate = chalk.gray(`{
"projects": {
"your-project": {
"architect": {
@@ -22,7 +22,7 @@ export async function configureAngularSourcemapGenerationFlow(): Promise {
`Enable generating source maps in your ${chalk.bold('angular.json')} file:`,
);
- // Intentially logging directly to console here so that the code can be copied/pasted directly
+ // Intentionally logging directly to console here so that the code can be copied/pasted directly
// eslint-disable-next-line no-console
console.log(angularJsonTemplate);
diff --git a/src/sourcemaps/tools/sentry-cli.ts b/src/sourcemaps/tools/sentry-cli.ts
index 64990457..60a748fe 100644
--- a/src/sourcemaps/tools/sentry-cli.ts
+++ b/src/sourcemaps/tools/sentry-cli.ts
@@ -23,6 +23,7 @@ let addedToBuildCommand = false;
export async function configureSentryCLI(
options: SourceMapUploadToolConfigurationOptions,
configureSourcemapGenerationFlow: () => Promise = defaultConfigureSourcemapGenerationFlow,
+ skipValidation = false,
): Promise {
const packageDotJson = await getPackageDotJson();
@@ -33,6 +34,7 @@ export async function configureSentryCLI(
let validPath = false;
let relativeArtifactPath;
+
do {
const rawArtifactPath = await abortIfCancelled(
clack.text({
@@ -76,7 +78,9 @@ export async function configureSentryCLI(
.split(path.sep)
.join(path.posix.sep);
- await configureSourcemapGenerationFlow();
+ if (!skipValidation) {
+ await configureSourcemapGenerationFlow();
+ }
await createAndAddNpmScript(options, relativePosixArtifactPath);
diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts
index b31fcae3..3b1d0bfd 100644
--- a/src/utils/clack-utils.ts
+++ b/src/utils/clack-utils.ts
@@ -875,6 +875,7 @@ export function isUsingTypeScript() {
export async function getOrAskForProjectData(
options: WizardOptions,
platform?:
+ | 'javascript-angular'
| 'javascript-nextjs'
| 'javascript-nuxt'
| 'javascript-remix'
@@ -1037,6 +1038,7 @@ async function askForWizardLogin(options: {
url: string;
promoCode?: string;
platform?:
+ | 'javascript-angular'
| 'javascript-nextjs'
| 'javascript-nuxt'
| 'javascript-remix'