diff --git a/webapp/Dockerfile b/webapp/Dockerfile index 2b45d02b..7cab5188 100644 --- a/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -24,6 +24,11 @@ RUN set -a && \ export const environment = { clientUrl: '${APPLICATION_CLIENT_URL}', serverUrl: '${APPLICATION_SERVER_URL}', + version: '${APPLICATION_VERSION}', + sentry: { + dsn: '${SENTRY_DSN}', + environment: 'prod', + }, keycloak: { url: '${KEYCLOAK_URL}', realm: '${KEYCLOAK_REALM}', diff --git a/webapp/angular.json b/webapp/angular.json index fef40ff5..a2a9e259 100644 --- a/webapp/angular.json +++ b/webapp/angular.json @@ -78,7 +78,13 @@ "maximumError": "10kb" } ], - "outputHashing": "all" + "outputHashing": "all", + "sourceMap": { + "scripts": true, + "styles": false, + "hidden": false, + "vendor": false + } }, "development": { "optimization": false, diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 19f24322..8a8a7591 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -21,6 +21,7 @@ "@ng-icons/lucide": "^26.3.0", "@ng-icons/octicons": "29.5.0", "@primer/primitives": "9.1.1", + "@sentry/angular": "^8.42.0", "@spartan-ng/ui-accordion-brain": "0.0.1-alpha.356", "@spartan-ng/ui-alertdialog-brain": "0.0.1-alpha.356", "@spartan-ng/ui-avatar-brain": "0.0.1-alpha.356", @@ -6401,6 +6402,101 @@ } } }, + "node_modules/@sentry-internal/browser-utils": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.42.0.tgz", + "integrity": "sha512-xzgRI0wglKYsPrna574w1t38aftuvo44gjOKFvPNGPnYfiW9y4m+64kUz3JFbtanvOrKPcaITpdYiB4DeJXEbA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.42.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.42.0.tgz", + "integrity": "sha512-dkIw5Wdukwzngg5gNJ0QcK48LyJaMAnBspqTqZ3ItR01STi6Z+6+/Bt5XgmrvDgRD+FNBinflc5zMmfdFXXhvw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.42.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.42.0.tgz", + "integrity": "sha512-oNcJEBlDfXnRFYC5Mxj5fairyZHNqlnU4g8kPuztB9G5zlsyLgWfPxzcn1ixVQunth2/WZRklDi4o1ZfyHww7w==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.42.0", + "@sentry/core": "8.42.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.42.0.tgz", + "integrity": "sha512-XrPErqVhPsPh/oFLVKvz7Wb+Fi2J1zCPLeZCxWqFuPWI2agRyLVu0KvqJyzSpSrRAEJC/XFzuSVILlYlXXSfgA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "8.42.0", + "@sentry/core": "8.42.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/angular": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.42.0.tgz", + "integrity": "sha512-gQ3gHNw7FadlLEtE57l9AZ2bkW1bVAk8FnbOkpc3NXkBJTKtxWODbhqCGDxGOWplJGzVOJ4EmXU2GHm7APOdwA==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "8.42.0", + "@sentry/core": "8.42.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=14.18" + }, + "peerDependencies": { + "@angular/common": ">= 14.x <= 19.x", + "@angular/core": ">= 14.x <= 19.x", + "@angular/router": ">= 14.x <= 19.x", + "rxjs": "^6.5.5 || ^7.x" + } + }, + "node_modules/@sentry/browser": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.42.0.tgz", + "integrity": "sha512-lStrEk609KJHwXfDrOgoYVVoFFExixHywxSExk7ZDtwj2YPv6r6Y1gogvgr7dAZj7jWzadHkxZ33l9EOSJBfug==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.42.0", + "@sentry-internal/feedback": "8.42.0", + "@sentry-internal/replay": "8.42.0", + "@sentry-internal/replay-canvas": "8.42.0", + "@sentry/core": "8.42.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/core": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.42.0.tgz", + "integrity": "sha512-ac6O3pgoIbU6rpwz6LlwW0wp3/GAHuSI0C5IsTgIY6baN8rOBnlAtG6KrHDDkGmUQ2srxkDJu9n1O6Td3cBCqw==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, "node_modules/@sigstore/bundle": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz", diff --git a/webapp/package.json b/webapp/package.json index 1aa1854f..463c2d1a 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -33,6 +33,7 @@ "@ng-icons/lucide": "^26.3.0", "@ng-icons/octicons": "29.5.0", "@primer/primitives": "9.1.1", + "@sentry/angular": "^8.42.0", "@spartan-ng/ui-accordion-brain": "0.0.1-alpha.356", "@spartan-ng/ui-alertdialog-brain": "0.0.1-alpha.356", "@spartan-ng/ui-avatar-brain": "0.0.1-alpha.356", diff --git a/webapp/src/app/app.component.ts b/webapp/src/app/app.component.ts index 28fb1a29..48d18c45 100644 --- a/webapp/src/app/app.component.ts +++ b/webapp/src/app/app.component.ts @@ -1,19 +1,25 @@ -import { Component, isDevMode } from '@angular/core'; +import { Component, inject, isDevMode } from '@angular/core'; import { AngularQueryDevtools } from '@tanstack/angular-query-devtools-experimental'; -import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { RouterOutlet } from '@angular/router'; import { HeaderComponent } from '@app/core/header/header.component'; import { FooterComponent } from './core/footer/footer.component'; +import { SentryErrorHandler } from './core/sentry/sentry.error-handler'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, RouterLink, RouterLinkActive, AngularQueryDevtools, HeaderComponent, FooterComponent], + imports: [RouterOutlet, AngularQueryDevtools, HeaderComponent, FooterComponent], templateUrl: './app.component.html' }) export class AppComponent { title = 'Hephaestus'; + sentry = inject(SentryErrorHandler); isDevMode() { return isDevMode(); } + + constructor() { + this.sentry.init(); + } } diff --git a/webapp/src/app/app.config.ts b/webapp/src/app/app.config.ts index 4e64ab39..3da5f04e 100644 --- a/webapp/src/app/app.config.ts +++ b/webapp/src/app/app.config.ts @@ -1,5 +1,5 @@ -import { APP_INITIALIZER, ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { APP_INITIALIZER, ApplicationConfig, ErrorHandler, provideExperimentalZonelessChangeDetection } from '@angular/core'; +import { provideRouter, Router } from '@angular/router'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideAngularQuery, QueryClient } from '@tanstack/angular-query-experimental'; @@ -8,6 +8,8 @@ import { BASE_PATH } from 'app/core/modules/openapi'; import { routes } from 'app/app.routes'; import { AnalyticsService } from './analytics.service'; import { securityInterceptor } from './core/security/security-interceptor'; +import { TraceService } from '@sentry/angular'; +import { SentryErrorHandler } from './core/sentry/sentry.error-handler'; function initializeAnalytics(analyticsService: AnalyticsService): () => void { return () => { @@ -23,6 +25,14 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withInterceptors([securityInterceptor])), provideAnimationsAsync(), { provide: BASE_PATH, useValue: environment.serverUrl }, - { provide: APP_INITIALIZER, useFactory: initializeAnalytics, multi: true, deps: [AnalyticsService] } + { provide: APP_INITIALIZER, useFactory: initializeAnalytics, multi: true, deps: [AnalyticsService] }, + { provide: ErrorHandler, useClass: SentryErrorHandler }, + { provide: TraceService, deps: [Router] }, + { + provide: APP_INITIALIZER, + useFactory: () => () => {}, + deps: [TraceService], + multi: true + } ] }; diff --git a/webapp/src/app/core/security/security-store.service.ts b/webapp/src/app/core/security/security-store.service.ts index 20add32c..94413202 100644 --- a/webapp/src/app/core/security/security-store.service.ts +++ b/webapp/src/app/core/security/security-store.service.ts @@ -2,6 +2,7 @@ import { computed, inject, Injectable, PLATFORM_ID, signal } from '@angular/core import { isPlatformServer } from '@angular/common'; import { KeycloakService } from './keycloak.service'; import { ANONYMOUS_USER, User } from './models'; +import { setUser } from '@sentry/angular'; @Injectable({ providedIn: 'root' }) export class SecurityStore { @@ -23,6 +24,7 @@ export class SecurityStore { if (isServer) { this.user.set(ANONYMOUS_USER); this.loaded.set(true); + setUser(ANONYMOUS_USER); return; } @@ -40,9 +42,11 @@ export class SecurityStore { }; this.user.set(user); this.loaded.set(true); + setUser(user); } else { this.user.set(ANONYMOUS_USER); this.loaded.set(true); + setUser(ANONYMOUS_USER); } } diff --git a/webapp/src/app/core/sentry/sentry.error-handler.ts b/webapp/src/app/core/sentry/sentry.error-handler.ts new file mode 100644 index 00000000..2bde1073 --- /dev/null +++ b/webapp/src/app/core/sentry/sentry.error-handler.ts @@ -0,0 +1,47 @@ +import { ErrorHandler, Injectable } from '@angular/core'; +import { environment } from 'environments/environment'; +import * as Sentry from '@sentry/angular'; + +@Injectable({ providedIn: 'root' }) +export class SentryErrorHandler extends ErrorHandler { + private environment = environment; + + constructor() { + super(); + } + + /** + * Initialize Sentry with environment. + */ + async init() { + const env = this.environment; + if (!env || !env.version || !env.sentry?.dsn) { + return; + } + + Sentry.init({ + dsn: env.sentry.dsn, + release: env.version, + environment: env.sentry.environment, + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: env.sentry.environment !== 'prod' ? 1.0 : 0.2 + }); + } + + /** + * Send an HttpError to Sentry. Only if it's not in the range 400-499. + * @param error + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override handleError(error: any): void { + if (error && error.name === 'HttpErrorResponse' && error.status < 500 && error.status >= 400) { + super.handleError(error); + return; + } + if (this.environment.sentry.environment !== 'local') { + const exception = error.error || error.message || error.originalError || error; + Sentry.captureException(exception); + } + super.handleError(error); + } +} diff --git a/webapp/src/environments/environment.prod.ts b/webapp/src/environments/environment.prod.ts index c7e2ad97..a409952e 100644 --- a/webapp/src/environments/environment.prod.ts +++ b/webapp/src/environments/environment.prod.ts @@ -1,6 +1,11 @@ export const environment = { clientUrl: 'http://localhost:4200', serverUrl: 'http://localhost:8080', + version: '0.0.1', + sentry: { + dsn: 'https://289f1f62feeb4f70a8878dc0101825cd@sentry.ase.in.tum.de/3', + environment: 'prod' + }, keycloak: { url: 'http://localhost:8081', realm: 'hephaestus', diff --git a/webapp/src/environments/environment.ts b/webapp/src/environments/environment.ts index c7e2ad97..a409952e 100644 --- a/webapp/src/environments/environment.ts +++ b/webapp/src/environments/environment.ts @@ -1,6 +1,11 @@ export const environment = { clientUrl: 'http://localhost:4200', serverUrl: 'http://localhost:8080', + version: '0.0.1', + sentry: { + dsn: 'https://289f1f62feeb4f70a8878dc0101825cd@sentry.ase.in.tum.de/3', + environment: 'prod' + }, keycloak: { url: 'http://localhost:8081', realm: 'hephaestus',