diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 5d23c4f..cdf0f0c 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,6 @@ import { ApplicationConfig, + ErrorHandler, provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, @@ -11,6 +12,7 @@ import { provideRouter, withComponentInputBinding } from '@angular/router'; import { routes } from './app.routes'; import { authInterceptor } from './core/auth/auth.interceptor'; import { AuthService } from './core/auth/auth.service'; +import { ErrorReportingService, TelemetryErrorHandler } from './core/error-reporting.service'; export const appConfig: ApplicationConfig = { providers: [ @@ -22,5 +24,14 @@ export const appConfig: ApplicationConfig = { // admin guard runs against an empty session and bounces a legitimate // administrator to the forbidden page. provideAppInitializer(() => inject(AuthService).init()), + { provide: ErrorHandler, useClass: TelemetryErrorHandler }, + provideAppInitializer(() => { + const reporter = inject(ErrorReportingService); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') reporter.flushNow(); + }); + } + }), ], }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3186076..3a19ea9 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -43,6 +43,11 @@ export const routes: Routes = [ title: 'Orders · OpenTaberna Admin', loadComponent: () => import('./features/orders/orders.page').then((m) => m.OrdersPage), }, + { + path: 'errors', + title: 'Frontend errors · OpenTaberna Admin', + loadComponent: () => import('./features/errors/errors.page').then((m) => m.ErrorsPage), + }, { path: 'users', title: 'Users · OpenTaberna Admin', diff --git a/src/app/core/api/errors.service.ts b/src/app/core/api/errors.service.ts new file mode 100644 index 0000000..8e6d73b --- /dev/null +++ b/src/app/core/api/errors.service.ts @@ -0,0 +1,23 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { FrontendErrors } from '../models/errors.models'; +import { ApiService } from './api.service'; + +/** + * Frontend error reports, under `/v1/admin/telemetry`. + * + * Covers both applications: the storefront's errors are read here because that + * is where an administrator looks, and commercially they matter most. + */ +@Injectable({ providedIn: 'root' }) +export class ErrorsService { + private readonly api = inject(ApiService); + + list(app?: 'storefront' | 'admin', limit = 25): Observable { + return this.api.get('/v1/admin/telemetry/errors', { + ...(app ? { app } : {}), + limit, + }); + } +} diff --git a/src/app/core/error-reporting.service.ts b/src/app/core/error-reporting.service.ts new file mode 100644 index 0000000..565011a --- /dev/null +++ b/src/app/core/error-reporting.service.ts @@ -0,0 +1,182 @@ +import { ErrorHandler, Injectable, inject } from '@angular/core'; +import { Router } from '@angular/router'; + +import { environment } from '../../environments/environment'; + +interface ErrorReport { + app: 'admin'; + name: string; + message: string; + stack?: string; + path?: string; + occurred_at: string; +} + +/** Reports of the same error beyond this are dropped for the rest of the session. */ +const MAX_PER_SIGNATURE = 3; + +/** Hard ceiling per session, whatever the signatures. */ +const MAX_PER_SESSION = 50; + +/** Batched to this size before an eager flush. */ +const BATCH_SIZE = 5; + +/** Idle time before a partial batch is sent anyway, in milliseconds. */ +const FLUSH_DELAY = 2000; + +/** The API truncates too; this stops the browser sending needless kilobytes. */ +const MAX_STACK_CHARS = 4000; + +/** + * Ships uncaught errors to the API. + * + * **It must never make things worse.** The failure mode being reported on is a + * component throwing inside a render loop, which will call this as fast as the + * browser can loop. So identical errors are reported at most three times per + * session, there is a hard per-session ceiling, and a failing endpoint is + * swallowed. A reporter that turns a render loop into a request loop has taken + * a broken page and made it a broken page plus a hammered API. + * + * **Off by default.** Cloning this repository must not start sending anything + * anywhere. + * + * Kept as a copy of the storefront's reporter rather than a shared package: two + * Angular applications in separate repositories would need a published library + * to share ~150 lines, and the versioning cost of that outweighs the + * duplication. Any change here belongs in both. + */ +@Injectable({ providedIn: 'root' }) +export class ErrorReportingService { + private readonly router = inject(Router); + private readonly config = environment.errorReporting; + // Absolute: the admin UI is served by the Angular dev server and the API + // lives on another origin, so a relative path would post to the wrong place. + private readonly endpoint = `${environment.apiBaseUrl}${environment.errorReporting.endpoint}`; + + private readonly seen = new Map(); + private queue: ErrorReport[] = []; + private timer: ReturnType | null = null; + private sessionTotal = 0; + + get enabled(): boolean { + return this.config.enabled; + } + + report(error: unknown): void { + if (!this.enabled || this.sessionTotal >= MAX_PER_SESSION) { + return; + } + + const { name, message, stack } = this.describe(error); + const signature = `${name}::${message}`; + + const count = (this.seen.get(signature) ?? 0) + 1; + this.seen.set(signature, count); + if (count > MAX_PER_SIGNATURE) { + return; + } + + this.sessionTotal++; + this.queue.push({ + app: 'admin', + name, + message, + stack: stack?.slice(0, MAX_STACK_CHARS), + // Route only. The API strips query strings as well, but a token in a URL + // should not travel to a request log on the way there. + path: this.router.url.split(/[?#]/)[0], + occurred_at: new Date().toISOString(), + }); + + if (this.queue.length >= BATCH_SIZE) { + this.flush(); + return; + } + this.timer ??= setTimeout(() => this.flush(), FLUSH_DELAY); + } + + /** Anything can be thrown in JavaScript, so nothing here may assume a shape. */ + private describe(error: unknown): { name: string; message: string; stack?: string } { + if (error instanceof Error) { + return { + name: error.name || 'Error', + message: (error.message || String(error)).slice(0, 500), + stack: error.stack, + }; + } + if (typeof error === 'object' && error !== null) { + const shaped = error as { name?: unknown; message?: unknown; stack?: unknown }; + return { + name: typeof shaped.name === 'string' ? shaped.name.slice(0, 120) : 'UnknownError', + message: + typeof shaped.message === 'string' + ? shaped.message.slice(0, 500) + : safeStringify(error).slice(0, 500), + stack: typeof shaped.stack === 'string' ? shaped.stack : undefined, + }; + } + return { name: 'UnknownError', message: String(error).slice(0, 500) }; + } + + private flush(beacon = false): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + if (this.queue.length === 0) { + return; + } + + const body = JSON.stringify({ errors: this.queue }); + this.queue = []; + + try { + if (beacon && typeof navigator !== 'undefined' && navigator.sendBeacon) { + navigator.sendBeacon(this.endpoint, new Blob([body], { type: 'application/json' })); + return; + } + void fetch(this.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => undefined); + } catch { + // Silent by design. If reporting an error throws, saying so would mean + // reporting that too. + } + } + + /** Send whatever is queued before the page goes away. */ + flushNow(): void { + this.flush(true); + } +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * Angular's global error handler. + * + * Still logs to the console. Swallowing that would take away the thing a + * developer looks at first, in exchange for a report they cannot see locally. + */ +@Injectable() +export class TelemetryErrorHandler implements ErrorHandler { + private readonly reporter = inject(ErrorReportingService); + + handleError(error: unknown): void { + try { + this.reporter.report(error); + } catch { + // Never let reporting mask the original error. + } + console.error(error); + } +} diff --git a/src/app/core/models/errors.models.ts b/src/app/core/models/errors.models.ts new file mode 100644 index 0000000..c974674 --- /dev/null +++ b/src/app/core/models/errors.models.ts @@ -0,0 +1,28 @@ +/** + * Frontend error reports, from `/v1/admin/telemetry/errors`. + * + * Grouped by application, error class and message — one bug produces thousands + * of identical rows, and grouping by stack would split a single fault reached + * from two routes into two bugs. + */ + +export interface ErrorGroup { + app: string; + name: string; + message: string; + occurrences: number; + /** Distinct routes this error happened on. Spread, where a stack cannot show it. */ + affected_paths: number; + /** Coarse family and major version. Never a raw user agent. */ + browsers: string[]; + first_seen: string; + last_seen: string; + sample_stack: string | null; +} + +export interface FrontendErrors { + /** False when the deployment is not collecting. Distinct from "no errors". */ + enabled: boolean; + total_occurrences: number; + groups: ErrorGroup[]; +} diff --git a/src/app/features/errors/errors.page.html b/src/app/features/errors/errors.page.html new file mode 100644 index 0000000..34dbb81 --- /dev/null +++ b/src/app/features/errors/errors.page.html @@ -0,0 +1,155 @@ +
+
+
+

Frontend errors

+

+ Uncaught errors from the storefront and this back office, last 7 days. +

+
+ +
+ @for (option of ['all', 'storefront', 'admin']; track option) { + + } +
+
+ + @if (loading()) { +
+ } @else if (failed()) { + +

Error reports could not be loaded.

+
+ } @else if (disabled()) { + +
+

Error reporting is switched off.

+

+ This deployment is not collecting frontend errors, so there is nothing to show — which is + not the same as there being no errors. Set + FRONTEND_ERRORS_ENABLED + on the API and enable reporting in each frontend's configuration. +

+
+
+ } @else { +
+
+

Distinct errors

+

{{ groups().length }}

+

+ {{ data()?.total_occurrences }} occurrence(s) in total +

+
+
+

Active in the last hour

+

+ {{ recent().length }} +

+

Still happening now

+
+
+

In the storefront

+

{{ storefrontCount() }}

+

Where a fault costs a sale

+
+
+ + @if (groups().length === 0) { + +

No errors reported in this period.

+
+ } @else { + +
    + @for (group of groups(); track key(group)) { +
  • + + + @if (isExpanded(group)) { +
    +
    +
    +
    First seen
    +
    {{ group.first_seen | date: 'short' }}
    +
    +
    +
    Last seen
    +
    {{ group.last_seen | date: 'short' }}
    +
    +
    +
    Routes affected
    +
    {{ group.affected_paths }}
    +
    +
    +
    Browsers
    +
    + {{ group.browsers.join(', ') || 'unknown' }} +
    +
    +
    + + @if (group.sample_stack) { +
    {{ group.sample_stack }}
    + } @else { +

    No stack trace was reported.

    + } +
    + } +
  • + } +
+
+ } + +

+ This shows only what browsers managed to send. An error that breaks a page badly enough to + stop the reporter never arrives, so a quiet list is + no news, not proof of no errors. +

+ } +
diff --git a/src/app/features/errors/errors.page.spec.ts b/src/app/features/errors/errors.page.spec.ts new file mode 100644 index 0000000..48c2495 --- /dev/null +++ b/src/app/features/errors/errors.page.spec.ts @@ -0,0 +1,108 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; + +import { ErrorsService } from '../../core/api/errors.service'; +import { ErrorGroup } from '../../core/models/errors.models'; +import { ErrorsPage } from './errors.page'; + +/** + * The screen's job is to distinguish three states that look alike if you are + * careless: nothing broken, nothing collected, and nothing loaded. + */ +describe('ErrorsPage', () => { + const minutesAgo = (n: number) => new Date(Date.now() - n * 60_000).toISOString(); + + const recent: ErrorGroup = { + app: 'storefront', + name: 'TypeError', + message: "Cannot read properties of undefined (reading 'price')", + occurrences: 42, + affected_paths: 3, + browsers: ['Chrome 140', 'Safari 18'], + first_seen: minutesAgo(90), + last_seen: minutesAgo(2), + sample_stack: 'at ProductComponent.render', + }; + + const stale: ErrorGroup = { + ...recent, + name: 'RangeError', + message: 'old and fixed', + occurrences: 100, + last_seen: minutesAgo(60 * 24 * 3), + app: 'admin', + }; + + function build( + response: unknown = { enabled: true, total_occurrences: 142, groups: [recent, stale] }, + ) { + TestBed.configureTestingModule({ + providers: [ + { + provide: ErrorsService, + useValue: { + list: () => (response === 'fail' ? throwError(() => new Error('down')) : of(response)), + }, + }, + ], + }); + const fixture = TestBed.createComponent(ErrorsPage); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it('renders and lists the groups it was given', () => { + const page = build(); + expect(page.loading()).toBe(false); + expect(page.groups()).toHaveLength(2); + }); + + it('counts only errors still happening as active', () => { + // A large total with nothing recent is a bug that has already been fixed. + const page = build(); + expect(page.recent()).toHaveLength(1); + expect(page.recent()[0].name).toBe('TypeError'); + }); + + it('marks a busy recent error as dangerous and an old one as neutral', () => { + const page = build(); + expect(page.toneFor(recent)).toBe('danger'); + expect(page.toneFor(stale)).toBe('neutral'); + }); + + it('counts storefront errors separately, since those cost sales', () => { + const page = build(); + expect(page.storefrontCount()).toBe(1); + }); + + it('distinguishes "not collecting" from "no errors"', () => { + // Both render an empty list unless the page says which it is. + const page = build({ enabled: false, total_occurrences: 0, groups: [] }); + expect(page.disabled()).toBe(true); + }); + + it('does not claim collection is off merely because there are no errors', () => { + const page = build({ enabled: true, total_occurrences: 0, groups: [] }); + expect(page.disabled()).toBe(false); + expect(page.groups()).toHaveLength(0); + }); + + it('reports a failed load rather than showing an empty list', () => { + const page = build('fail'); + expect(page.failed()).toBe(true); + expect(page.loading()).toBe(false); + }); + + it('expands one group at a time', () => { + const page = build(); + page.toggle(recent); + expect(page.isExpanded(recent)).toBe(true); + expect(page.isExpanded(stale)).toBe(false); + + page.toggle(stale); + expect(page.isExpanded(recent)).toBe(false); + + page.toggle(stale); + expect(page.isExpanded(stale)).toBe(false); + }); +}); diff --git a/src/app/features/errors/errors.page.ts b/src/app/features/errors/errors.page.ts new file mode 100644 index 0000000..b303c7a --- /dev/null +++ b/src/app/features/errors/errors.page.ts @@ -0,0 +1,103 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { ErrorsService } from '../../core/api/errors.service'; +import { ErrorGroup, FrontendErrors } from '../../core/models/errors.models'; +import { BadgeComponent, CardComponent, SpinnerComponent } from '../../shared/ui'; + +type AppFilter = 'all' | 'storefront' | 'admin'; + +/** + * Uncaught frontend errors, from both applications. + * + * Grouped by the API, because one bug produces thousands of identical rows. + * + * The storefront's errors are shown here rather than anywhere else: this is + * where an administrator looks, and commercially a broken storefront matters + * more than a broken back office. + * + * What this screen cannot show is the important caveat, and it is stated on the + * page rather than left to be inferred: an error that breaks a page badly + * enough to stop the reporter never arrives. Silence is no news, not no errors. + */ +@Component({ + selector: 'ot-errors-page', + standalone: true, + imports: [CommonModule, CardComponent, BadgeComponent, SpinnerComponent], + templateUrl: './errors.page.html', +}) +export class ErrorsPage { + private readonly errors = inject(ErrorsService); + + readonly loading = signal(true); + readonly failed = signal(false); + readonly data = signal(null); + readonly filter = signal('all'); + readonly expanded = signal(null); + + readonly disabled = computed(() => this.data()?.enabled === false); + readonly groups = computed(() => this.data()?.groups ?? []); + + /** + * Errors seen in the last hour, which is what "is something broken right + * now?" means. A large total with nothing recent is a fixed bug. + */ + readonly recent = computed(() => { + const hourAgo = Date.now() - 60 * 60 * 1000; + return this.groups().filter((g) => new Date(g.last_seen).getTime() > hourAgo); + }); + + readonly storefrontCount = computed( + () => this.groups().filter((g) => g.app === 'storefront').length, + ); + + constructor() { + this.load(); + } + + select(filter: AppFilter): void { + this.filter.set(filter); + this.load(); + } + + toggle(group: ErrorGroup): void { + const key = this.key(group); + this.expanded.set(this.expanded() === key ? null : key); + } + + key(group: ErrorGroup): string { + return `${group.app}::${group.name}::${group.message}`; + } + + isExpanded(group: ErrorGroup): boolean { + return this.expanded() === this.key(group); + } + + toneFor(group: ErrorGroup): 'danger' | 'warn' | 'neutral' { + const hourAgo = Date.now() - 60 * 60 * 1000; + if (new Date(group.last_seen).getTime() > hourAgo) { + return group.occurrences > 10 ? 'danger' : 'warn'; + } + return 'neutral'; + } + + private load(): void { + this.loading.set(true); + this.failed.set(false); + + const selected = this.filter(); + const app = selected === 'all' ? undefined : selected; + this.errors + .list(app, 50) + .pipe(catchError(() => of(null))) + .subscribe((result) => { + if (!result) { + this.failed.set(true); + } + this.data.set(result); + this.loading.set(false); + }); + } +} diff --git a/src/app/layout/nav.model.spec.ts b/src/app/layout/nav.model.spec.ts index 37a2b1d..534d8a6 100644 --- a/src/app/layout/nav.model.spec.ts +++ b/src/app/layout/nav.model.spec.ts @@ -1,7 +1,7 @@ import { NAV_ITEMS } from './nav.model'; describe('side navigation', () => { - it('exposes the seven administration areas, in working order', () => { + it('exposes the eight administration areas, in working order', () => { expect(NAV_ITEMS.map((i) => i.path)).toEqual([ '/dashboard', '/analytics', @@ -9,6 +9,7 @@ describe('side navigation', () => { '/inventory', '/orders', '/returns', + '/errors', '/users', ]); }); diff --git a/src/app/layout/nav.model.ts b/src/app/layout/nav.model.ts index 2eecf0c..19fc252 100644 --- a/src/app/layout/nav.model.ts +++ b/src/app/layout/nav.model.ts @@ -49,6 +49,12 @@ export const NAV_ITEMS: readonly NavItem[] = [ description: 'RMA requests', icon: 'M9 15 4 10l5-5m-5 5h10a6 6 0 0 1 0 12h-3', }, + { + label: 'Errors', + path: '/errors', + description: 'Uncaught frontend faults', + icon: 'M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z', + }, { label: 'Users', path: '/users', diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index e582f0b..7f6095f 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -7,6 +7,14 @@ export const environment = { production: true, apiBaseUrl: '/api', + /** + * Uncaught error reporting. Off by default; requires FRONTEND_ERRORS_ENABLED + * on the API, which otherwise answers the endpoint with a 404. + */ + errorReporting: { + enabled: false, + endpoint: '/v1/telemetry/errors', + }, keycloak: { url: 'https://auth.opentaberna.de', realm: 'opentaberna', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 5370cd3..5337bd8 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -11,6 +11,14 @@ export const environment = { production: false, apiBaseUrl: 'http://localhost:8000', + /** + * Uncaught error reporting. Off by default; requires FRONTEND_ERRORS_ENABLED + * on the API, which otherwise answers the endpoint with a 404. + */ + errorReporting: { + enabled: false, + endpoint: '/v1/telemetry/errors', + }, keycloak: { url: 'http://localhost:8080', realm: 'opentaberna',