From 4d0b54f234fec6eeff9741e05f4249b8182ab433 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 26 Aug 2026 17:19:04 +0200 Subject: [PATCH] feat(telemetry): report uncaught errors, and a screen to read them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin UI reported nothing when it broke, and nothing displayed the errors the API had started collecting — including the storefront's, which matter most commercially. Adds a global ErrorHandler, and an Errors screen showing grouped reports from both applications. The reporter carries the same guards as the storefront's, for the same reason: a component throwing during render calls the handler as fast as the browser can loop, so an identical error is reported at most three times per session, there is a hard per-session ceiling, and a failing endpoint is swallowed. Nothing assumes an Error shape, because anything can be thrown in JavaScript. It is 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 about 150 lines, and the versioning cost outweighs the duplication — noted in the file so the next person knows a change belongs in both. The screen distinguishes three states that look alike if you are careless: nothing broken, nothing collected, and nothing loaded. Only the second names the setting to change, and the third says the load failed rather than showing an empty list. "Active in the last hour" is separated from the total, because a large count with nothing recent is a bug that has already been fixed. The page states plainly that it 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 rather than proof of no errors. Closes #11 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4 --- src/app/app.config.ts | 11 ++ src/app/app.routes.ts | 5 + src/app/core/api/errors.service.ts | 23 +++ src/app/core/error-reporting.service.ts | 182 ++++++++++++++++++++ src/app/core/models/errors.models.ts | 28 +++ src/app/features/errors/errors.page.html | 155 +++++++++++++++++ src/app/features/errors/errors.page.spec.ts | 108 ++++++++++++ src/app/features/errors/errors.page.ts | 103 +++++++++++ src/app/layout/nav.model.spec.ts | 3 +- src/app/layout/nav.model.ts | 6 + src/environments/environment.prod.ts | 8 + src/environments/environment.ts | 8 + 12 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 src/app/core/api/errors.service.ts create mode 100644 src/app/core/error-reporting.service.ts create mode 100644 src/app/core/models/errors.models.ts create mode 100644 src/app/features/errors/errors.page.html create mode 100644 src/app/features/errors/errors.page.spec.ts create mode 100644 src/app/features/errors/errors.page.ts 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',