@browsonic/angular
v2.20.0
Published
Angular adapter for @browsonic/sdk — ErrorHandler, BrowsonicService, standalone provideBrowsonic() factory. Apache-2.0.
Maintainers
Readme
@browsonic/angular
Angular adapter for @browsonic/sdk — ErrorHandler drop-in, BrowsonicService injectable, standalone provideBrowsonic() factory, Router instrumentation, HttpClient companion.
Status: stable, published to npm — check
package.jsonor npm for the current version. Angular 17+ standalone style. Module-NgModule apps work via the same providers (see Quickstart — NgModule). Pure TypeScript: no Angular / RxJS runtime dependency in this package's bundle (@angular/core,@angular/router,@angular/common/http, andrxjsall stay peer-only type imports).
Install
npm install @browsonic/sdk @browsonic/angular@browsonic/sdk and @angular/core (≥17) are peer dependencies.
Quickstart — Angular 17+ standalone
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { ErrorHandler } from '@angular/core';
import { getBrowsonic } from '@browsonic/sdk';
import { provideBrowsonic, BrowsonicErrorHandler } from '@browsonic/angular';
import { AppComponent } from './app/app.component';
const sdk = getBrowsonic();
sdk.init({
apiEndpoint: 'https://your-ingest-endpoint.test/v1/events',
appKey: 'web',
apiKey: 'pk_live_...', // publishable key — safe to ship in the browser
});
bootstrapApplication(AppComponent, {
providers: [
...provideBrowsonic({ sdk }),
{ provide: ErrorHandler, useExisting: BrowsonicErrorHandler },
],
});Inject the service anywhere
import { Component, inject } from '@angular/core';
import { BrowsonicService } from '@browsonic/angular';
@Component({ selector: 'app-buy', template: '<button (click)="buy()">Buy</button>' })
export class BuyComponent {
private readonly browsonic = inject(BrowsonicService);
buy(): void {
try {
// …purchase logic
} catch (err) {
this.browsonic.captureError(err as Error);
}
}
}Quickstart — NgModule (Angular pre-standalone)
provideBrowsonic() is a standalone provider factory, but the values it returns are plain Provider[] — they work in AppModule.providers unchanged. Apps still on @NgModule({ ... }) bootstrap the same way:
// src/app/app.module.ts
import { NgModule, ErrorHandler } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { getBrowsonic } from '@browsonic/sdk';
import { provideBrowsonic, BrowsonicErrorHandler } from '@browsonic/angular';
import { AppComponent } from './app.component';
const sdk = getBrowsonic();
sdk.init({
apiEndpoint: 'https://your-ingest-endpoint.test/v1/events',
appKey: 'web',
apiKey: 'pk_live_...', // publishable key — safe to ship in the browser
});
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
providers: [
...provideBrowsonic({ sdk }),
{ provide: ErrorHandler, useExisting: BrowsonicErrorHandler },
],
bootstrap: [AppComponent],
})
export class AppModule {}Same provider tokens, same BrowsonicService injection sites. The only difference is where the providers array lives — bootstrapApplication for standalone, AppModule.providers for NgModule.
Router instrumentation
installRouterInstrumentation(router, options?) subscribes to Router.events, filters for NavigationEnd (isNavigationEnd(): Angular's EventType where stamped, else the one urlAfterRedirects event without a state snapshot — RoutesRecognized, Guards* and Resolve* carry urlAfterRedirects too and fire before the router commits), and emits a category: 'navigation' breadcrumb on every successful route change. Returns an unsubscribe() callable for HMR-friendly teardown.
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { Router } from '@angular/router';
import { installRouterInstrumentation } from '@browsonic/angular';
@Component({ selector: 'app-root', template: '<router-outlet/>' })
export class AppComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private off?: () => void;
ngOnInit(): void {
this.off = installRouterInstrumentation(this.router);
}
ngOnDestroy(): void {
this.off?.();
}
}The structural RouterLike shape only requires an events Observable, so test doubles work without @angular/router.
Atlas page views with route templates
When page-view tracking is on, each NavigationEnd also calls trackPageView(template, { name, nameKey, screen, url }) with:
- the route template — the matched route tree's parameterized path (
/users/:id), which the backend prefers over URL normalization; - the screen name — from the deepest matched route level that titles itself:
data['browsonicName'](explicit, verbatim), else the resolved routetitle(Angular 14+), else the first literal amongdata['title'],data['staticTitle'],data['mainTitle'](2.19.0;routeNameKeysreplaces that list). A translation key (headTitle.omni.accountSummary— a dotted path with no spaces) is never sent as a name, and a level titled only by a key yields no name rather than an ancestor's. Feeds the SDK ≥ 3.13 screen-name channel; - the screen context — the deepest matched route's
data['browsonicScreen']({ id, kind, zone }), feeding the SDK ≥ 3.17 Atlas screen-context channel; - the translation key (2.20.0) — when the deepest titled level names itself only through a key (
headTitle.omni.accountSummary), that key rides asnameKey(SDK ≥ 3.33nk) so the backend can still tell the screens apart; a key is still never sent as the name; - the router URL (2.20.0) —
urlAfterRedirectsas the address bar shows it, asurl:window.location.origin, then the path of the document's<base href>when there is one (<base href="/portal/">and/users/7give/portal/users/7, the wayPathLocationStrategywrites it), then the router URL. A navigation made withskipLocationChange(the router moved, the address bar did not) is thereby keyed to the screen the router is on, not the one being left. A hash-routed app (#/…,HashLocationStrategy) getsorigin + urlAfterRedirects, which the core'shashRoutingmakes of the address bar too; a base set only through theAPP_BASE_HREFprovider is not visible to the adapter. Older cores ignore both extra keys.
The trackPageViews option defaults to the core's atlas: true umbrella on SDK ≥ 3.14 (feature-detected via isAtlasEnabled()): when the host initialized with atlas: true, template tracking turns on with no second flag and the core hands organic page views over on the first template — no double counting. On older cores the default stays false; setting trackPageViews: true explicitly keeps the legacy contract (pair it with manualPageViews: true in the SDK init there).
// Route declarations feed all three channels:
const routes: Routes = [
{
path: 'billing/invoices/:id',
component: InvoiceDetailComponent,
data: {
browsonicName: 'Invoice Detail',
browsonicScreen: { id: 'billing.invoice-detail', kind: 'detail', zone: 'billing' },
},
},
];A route tree that already carries staticTitle / mainTitle (head-title or breadcrumb resolver fields) needs no route edits: from 2.19.0 the literal titles name the screens and the translation keys are skipped. To read another field, pass the list once at install time — installRouterInstrumentation(router, { routeNameKeys: [...DEFAULT_ROUTE_NAME_KEYS, 'pageTitle'] }).
The snapshot walkers behind this are exported for hosts that call trackPageView themselves — see routeTemplateFromSnapshot, routeNameFromSnapshot, routeNamingFromSnapshot, and routeScreenFromSnapshot.
HttpClient companion
createBrowsonicHttpReporter(options?) returns a (request, error) => void callback that captures HttpClient failures to the SDK. Wire it into your own HttpInterceptor so the adapter stays peer-only on @angular/common/http and rxjs:
// src/app/browsonic-http.interceptor.ts
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpHandler, HttpRequest, HttpEvent } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { createBrowsonicHttpReporter } from '@browsonic/angular';
@Injectable()
export class BrowsonicHttpInterceptor implements HttpInterceptor {
private readonly report = createBrowsonicHttpReporter({
// Skip Browsonic's own ingest URL so a failed report can't trigger
// another report (would loop).
ignoreUrls: [/\/v1\/events$/],
// Suppress 401 / 404 — surfaced at the UI layer, not in the dashboard.
ignoreStatuses: [401, 404],
});
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(req).pipe(
catchError((err: unknown) => {
this.report(req, err);
return throwError(() => err);
}),
);
}
}Register it via HTTP_INTERCEPTORS — standalone or NgModule, both shapes work:
// Standalone (app.config.ts)
import { provideHttpClient, withInterceptorsFromDi, HTTP_INTERCEPTORS } from '@angular/common/http';
providers: [
provideHttpClient(withInterceptorsFromDi()),
{ provide: HTTP_INTERCEPTORS, useClass: BrowsonicHttpInterceptor, multi: true },
];
// NgModule (AppModule.providers)
import { HTTP_INTERCEPTORS } from '@angular/common/http';
providers: [{ provide: HTTP_INTERCEPTORS, useClass: BrowsonicHttpInterceptor, multi: true }];The reporter tags the active scope with angular.http.method + angular.http.status and attaches httpUrl (capped at 256 chars). Two captures are off by default, because both are places an API commonly echoes back the caller's own PII: the error response body (maxBodyLength: <chars> opts in) and the query string (includeQueryParams: true opts in — without it the bare path is reported). See http-interceptor.ts for all options.
API
BrowsonicErrorHandler
Drop-in for Angular's ErrorHandler provider. Implements the framework's duck-typed handleError(error: unknown): void shape. Calls sdk.captureError, then forwards to console.error so the Angular default dev-tools experience is preserved (consoleFallback: false to disable).
BrowsonicService
Injectable wrapper around the SDK. Methods: setUser, clearUser, captureError, captureMessage, addBreadcrumb, setTag, getSdk — and, since 2.20.0, identify(identity), clearIdentity(), setPersona(persona | null) and newSession() (returns the new session id). The four identity wrappers are feature-detected: the peer range starts at SDK 3.22, which predates newSession (3.27) and setPersona (3.28), so on an older core they are no-ops and newSession() returns '' — never a TypeError in the host.
provideBrowsonic(options?)
Standalone-style provider factory. Returns the providers array that registers BrowsonicErrorHandler and BrowsonicService as singletons. Wire the framework's ErrorHandler token to useExisting: BrowsonicErrorHandler separately so consumers control whether the handler also relays to a custom upstream handler (we don't chain by default — Angular's ErrorHandler is single-binding).
installRouterInstrumentation(router, options?)
Subscribes to a RouterLike.events Observable and emits a category: 'navigation' breadcrumb on every NavigationEnd. When page-view tracking is on (trackPageViews, defaulting to the core's atlas: true umbrella on SDK ≥ 3.14), each navigation also sends trackPageView with the route template, screen name, and screen context. Returns an unsubscribe() callable. Structural RouterLike / RouterEventLike types — no @angular/router runtime import. See Router instrumentation.
routeTemplateFromSnapshot(root)
Joins the matched route tree's routeConfig.path segments into the parameterized route template (/users/:id/orders) — the authoritative form the Atlas backend prefers over URL normalization. Walks the primary-outlet chain (depth-capped); empty-path levels (componentless / lazy wrappers) are skipped. Returns '' when nothing matched, so callers can omit the field and fall back to URL normalization.
isNavigationEnd(event)
Whether a router event is Angular's NavigationEnd (2.19.1). urlAfterRedirects alone is not enough: five pre-commit events of the same navigation carry it as well, while the browser URL and the snapshot still describe the screen being left. Recognised by event.type === 1 (Angular ≥ 14.1) or, without a type, by the missing state / shouldActivate.
routeNameFromSnapshot(root, keys?)
Returns the name declared by the deepest matched route level that titles itself — data['browsonicName'] (explicit, verbatim), else the resolved route title (Angular 14+), else the first of keys (default DEFAULT_ROUTE_NAME_KEYS: title, staticTitle, mainTitle) holding a literal — feeding the SDK ≥ 3.13 screen-name channel. Returns '' when no level does. A level that declares nothing inherits its parent's name (Angular's own TitleStrategy walks the tree the same way); a level whose only title is a translation key yields '' instead of an ancestor's literal, so two screens never share a name they do not share.
routeNamingFromSnapshot(root, keys?)
The name AND the translation key together (2.20.0): { name, nameKey } from the deepest matched route level that titles itself — name is what routeNameFromSnapshot returns, nameKey the first key that level declared ('' when it declared a literal only, or nothing). A key-only level yields name: '' with its key. This is what the router instrumentation forwards as { name, nameKey }.
looksLikeTranslationKey(value)
The rule the name channel applies (2.19.0): a dotted identifier path with no whitespace (headTitle.omni.accountSummary, HOME.TITLE) is a translation key, not a name. Exported so a host can unit-test its own route data against it.
routeScreenFromSnapshot(root)
Returns the deepest matched route's data['browsonicScreen'] declaration ({ id, kind, zone }, typed as RouteScreenContextLike) for the SDK ≥ 3.17 Atlas screen-context channel, or undefined when no level declares one. Deepest declaration wins wholesale (no per-field merging across levels). Only string fields are forwarded; the SDK normalizes and validates each field independently, so an invalid value never breaks the page view.
createBrowsonicHttpReporter(options?)
Returns a (request, error) => void callback that captures HttpClient failures. Filters by ignoreUrls (string or RegExp) and ignoreStatuses. Tags the scope with <ns>.method + <ns>.status and attaches httpUrl (bare path unless includeQueryParams: true; response body only when maxBodyLength is set above 0). Designed to be wired into a consumer-owned HttpInterceptor so the adapter stays peer-only on @angular/common/http and rxjs. See HttpClient companion.
Defensive contract
- The host app must never crash because reporting failed.
- All SDK calls are wrapped in
try { … } catch {}. BrowsonicErrorHandler.handleErrornever throws — even when both the SDK andconsole.errorraise, the method returns silently.- All forwarders are no-ops when the SDK is unreachable.
Optional /decorated entry-point — @Injectable + signals
If you don't mind @angular/core running at runtime in this package's bundle (rather than the default type-only contract), you can import from @browsonic/angular/decorated to get:
BrowsonicDecoratedService—@Injectable({ providedIn: 'root' })version ofBrowsonicService. No more provider wiring; justinject(BrowsonicDecoratedService)from any component.provideBrowsonicUserSignal(options?)— provider factory that bridges aWritableSignal<UserContext | null>tosdk.setUser. Set the signal tonullto clear the user.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideBrowsonicUserSignal } from '@browsonic/angular/decorated';
import { provideBrowsonic, BrowsonicErrorHandler } from '@browsonic/angular';
import { ErrorHandler } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
...provideBrowsonic({ sdk }),
{ provide: ErrorHandler, useExisting: BrowsonicErrorHandler },
...provideBrowsonicUserSignal({ initial: null }),
],
};
// any component
import { Component, inject } from '@angular/core';
import { BROWSONIC_USER_SIGNAL } from '@browsonic/angular/decorated';
@Component({ selector: 'app-login', template: '...' })
export class LoginComponent {
private readonly user = inject(BROWSONIC_USER_SIGNAL);
onLogin(profile: { id: string; email: string }) {
this.user.set(profile);
}
}The default @browsonic/angular entry stays peer-only on @angular/core. Pick /decorated only if you want the decorator + signal ergonomics.
What this package does NOT do
- NgZone-aware error capture. The default Angular zone catches errors; our
ErrorHandlerruns inside the zone. If your app usesNgZone.runOutsideAngular, errors there don't reachErrorHandler— callbrowsonic.captureError(err)from yourtry/catchblock manually. - Server-side rendering capture. Angular SSR runs in Node; the SDK is browser-only. Wire your own server-side telemetry.
License
Apache-2.0. See the repo root LICENSE and the package NOTICE.
