diff --git a/common/api-review/crashlytics-angular.api.md b/common/api-review/crashlytics-angular.api.md index ca7b678fc8..6ac98b8d24 100644 --- a/common/api-review/crashlytics-angular.api.md +++ b/common/api-review/crashlytics-angular.api.md @@ -4,8 +4,10 @@ ```ts +import { DestroyRef } from '@angular/core'; import { ErrorHandler } from '@angular/core'; import { FirebaseApp } from '@firebase/app'; +import { Router } from '@angular/router'; // @public export interface Crashlytics { @@ -27,6 +29,9 @@ export class FirebaseErrorHandler implements ErrorHandler { handleError(error: unknown): void; } +// @public +export function setupNavigationTracking(app: FirebaseApp, router: Router, destroyRef: DestroyRef, crashlyticsOptions?: CrashlyticsOptions): void; + // (No @packageDocumentation comment for this package) diff --git a/common/api-review/crashlytics-next-navigation.api.md b/common/api-review/crashlytics-next-navigation.api.md index 8edbce862a..2c27bf1cd5 100644 --- a/common/api-review/crashlytics-next-navigation.api.md +++ b/common/api-review/crashlytics-next-navigation.api.md @@ -25,9 +25,6 @@ export interface CrashlyticsOptions { tracingUrl?: string; } -// @public -export function getParameterizedRoute(pathname: string | null, params: Record | null): string; - // (No @packageDocumentation comment for this package) diff --git a/docs-devsite/crashlytics_angular.md b/docs-devsite/crashlytics_angular.md index 55e8b5c589..fa6c0aa402 100644 --- a/docs-devsite/crashlytics_angular.md +++ b/docs-devsite/crashlytics_angular.md @@ -11,6 +11,12 @@ https://github.com/firebase/firebase-js-sdk # @firebase/crashlytics/angular +## Functions + +| Function | Description | +| --- | --- | +| [setupNavigationTracking(app, router, destroyRef, crashlyticsOptions)](./crashlytics_angular.md#setupnavigationtracking_93fc190) | Configures automatic Angular router navigation tracking for Firebase Crashlytics.This function subscribes to router navigation events, keeps the Crashlytics route path attribute updated, and logs view boundary telemetry automatically. | + ## Classes | Class | Description | @@ -24,3 +30,57 @@ https://github.com/firebase/firebase-js-sdk | [Crashlytics](./crashlytics_angular.crashlytics.md#crashlytics_interface) | An instance of the Firebase Crashlytics SDK.Do not create this instance directly. Instead, use [getCrashlytics()](./crashlytics_.md#getcrashlytics_a9d22a1). | | [CrashlyticsOptions](./crashlytics_angular.crashlyticsoptions.md#crashlyticsoptions_interface) | Options for initializing the Crashlytics service using [getCrashlytics()](./crashlytics_.md#getcrashlytics_a9d22a1). | +## function(app, ...) + +### setupNavigationTracking(app, router, destroyRef, crashlyticsOptions) {:#setupnavigationtracking_93fc190} + +Configures automatic Angular router navigation tracking for Firebase Crashlytics. + +This function subscribes to router navigation events, keeps the Crashlytics route path attribute updated, and logs view boundary telemetry automatically. + +Signature: + +```typescript +export declare function setupNavigationTracking(app: FirebaseApp, router: Router, destroyRef: DestroyRef, crashlyticsOptions?: CrashlyticsOptions): void; +``` + +#### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| app | [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) | The [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) instance to use. | +| router | Router | The Angular instance to subscribe to. | +| destroyRef | DestroyRef | The instance to bind teardown logic to. | +| crashlyticsOptions | [CrashlyticsOptions](./crashlytics_.crashlyticsoptions.md#crashlyticsoptions_interface) | Optional. [CrashlyticsOptions](./crashlytics_.crashlyticsoptions.md#crashlyticsoptions_interface) that configure the Crashlytics instance. | + +Returns: + +void + +### Example + + +```typescript +import { ApplicationConfig, ErrorHandler, inject, DestroyRef } from '@angular/core'; +import { Router } from '@angular/router'; +import { FirebaseErrorHandler, setupNavigationTracking } from '@firebase/crashlytics/angular'; + +export const appConfig: ApplicationConfig = { + providers: [ + { + provide: ErrorHandler, + useFactory: () => new FirebaseErrorHandler(firebaseApp) + }, + provideEnvironmentInitializer(() => { + inject(ErrorHandler); + setupNavigationTracking( + firebaseApp, + inject(Router), + inject(DestroyRef) + ); + }) + ] +}; + +``` + diff --git a/docs-devsite/crashlytics_next-navigation.md b/docs-devsite/crashlytics_next-navigation.md index 1bb2f2a171..8fd739d509 100644 --- a/docs-devsite/crashlytics_next-navigation.md +++ b/docs-devsite/crashlytics_next-navigation.md @@ -15,10 +15,7 @@ https://github.com/firebase/firebase-js-sdk | Function | Description | | --- | --- | -| function({ firebaseApp, crashlyticsOptions }, ...) | | [CrashlyticsNavigationTracker({ firebaseApp, crashlyticsOptions })](./crashlytics_next-navigation.md#crashlyticsnavigationtracker_c141224) | A client-side routing component for Next.js App Router that automatically captures navigation events and updates route attributes.This component should be mounted inside a root Client Component layout. | -| function(pathname, ...) | -| [getParameterizedRoute(pathname, params)](./crashlytics_next-navigation.md#getparameterizedroute_3fcbe19) | Constructs a parameterized route template for Next.js App Router by replacing dynamic parameter values in the pathname with parameter placeholders. | ## Interfaces @@ -74,30 +71,3 @@ export default function RootLayout({ children }) { ``` -## function(pathname, ...) - -### getParameterizedRoute(pathname, params) {:#getparameterizedroute_3fcbe19} - -Constructs a parameterized route template for Next.js App Router by replacing dynamic parameter values in the pathname with parameter placeholders. - -Signature: - -```typescript -export declare function getParameterizedRoute(pathname: string | null, params: Record | null): string; -``` - -#### Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| pathname | string \| null | | -| params | Record<string, string \| string\[\] \| undefined> \| null | | - -Returns: - -string - -### Example - -// pathname = "/users/123/details", params = { id: "123" } // returns "/users/:id/details" - diff --git a/packages/crashlytics/src/angular/index.test.ts b/packages/crashlytics/src/angular/index.test.ts index ec2dd0da35..d340a63900 100644 --- a/packages/crashlytics/src/angular/index.test.ts +++ b/packages/crashlytics/src/angular/index.test.ts @@ -20,17 +20,23 @@ import 'zone.js/testing'; import { expect, use } from 'chai'; import sinonChai from 'sinon-chai'; import chaiAsPromised from 'chai-as-promised'; -import { restore, stub } from 'sinon'; +import sinon, { restore, stub } from 'sinon'; import { FirebaseApp, deleteApp, initializeApp } from '@firebase/app'; import * as crashlytics from '../api'; import { Component, + DestroyRef, Injector, provideZoneChangeDetection, runInInjectionContext } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { FirebaseErrorHandler } from '.'; +import { + FirebaseErrorHandler, + setupNavigationTracking, + getSafeRoutePath, + getRawPath +} from '.'; import { Crashlytics } from '../public-types'; import { Router, RouterModule } from '@angular/router'; import { @@ -45,11 +51,10 @@ use(chaiAsPromised); TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); @Component({ template: '' }) -class DummyComponent {} +class MockComponent {} describe('FirebaseErrorHandler', () => { let errorHandler: FirebaseErrorHandler; - let router: Router; let app: FirebaseApp; let fakeCrashlytics: Crashlytics; @@ -73,8 +78,8 @@ describe('FirebaseErrorHandler', () => { TestBed.configureTestingModule({ imports: [ RouterModule.forRoot([ - { path: 'static-route', component: DummyComponent }, - { path: 'dynamic/:id/route', component: DummyComponent } + { path: 'static-route', component: MockComponent }, + { path: 'dynamic/:id/route', component: MockComponent } ]) ], providers: [provideZoneChangeDetection()] @@ -84,7 +89,6 @@ describe('FirebaseErrorHandler', () => { testInjector, () => new FirebaseErrorHandler(app) ); - router = TestBed.inject(Router); }); afterEach(async () => { @@ -92,28 +96,210 @@ describe('FirebaseErrorHandler', () => { await deleteApp(app); }); + it('should register routePath provider in attributesStore and return correct route path', async () => { + const setRoutePathProviderSpy = sinon.spy( + attributesStore, + 'setRoutePathProvider' + ); + const testInjector = TestBed.inject(Injector); + runInInjectionContext(testInjector, () => new FirebaseErrorHandler(app)); + expect(setRoutePathProviderSpy).to.have.been.calledWith(sinon.match.func); + + const provider = setRoutePathProviderSpy.firstCall.args[0]; + const router = TestBed.inject(Router); + await router.navigate(['/static-route']); + expect(provider).to.not.be.undefined; + expect(provider!()).to.equal('/static-route'); + }); + it('should log the error to the console', async () => { const testError = new Error('Test error message'); errorHandler.handleError(testError); expect(getCrashlyticsStub).to.have.been.called; expect(recordErrorStub).to.have.been.calledWith(fakeCrashlytics, testError); }); +}); - describe('routePath integration', () => { - it('should set the routePath attribute', async () => { - await router.navigate(['/static-route']); +describe('getSafeRoutePath', () => { + let router: Router; - expect( - attributesStore.getLogAttributes()[LOG_ATTR_KEY.ROUTE_PATH] - ).to.equal('/static-route'); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterModule.forRoot([ + { path: 'static-route', component: MockComponent }, + { path: 'dynamic/:id/route', component: MockComponent } + ]) + ], + providers: [provideZoneChangeDetection()] }); + router = TestBed.inject(Router); + }); + + it('should return the static route path', async () => { + await router.navigate(['/static-route']); + expect(getSafeRoutePath(router)).to.equal('/static-route'); + }); - it('should remove dynamic content from route', async () => { - await router.navigate(['/dynamic/my-name/route']); + it('should return the parameterized route pattern', async () => { + await router.navigate(['/dynamic/my-name/route']); + expect(getSafeRoutePath(router)).to.equal('/dynamic/:id/route'); + }); +}); - expect( - attributesStore.getLogAttributes()[LOG_ATTR_KEY.ROUTE_PATH] - ).to.equal('/dynamic/:id/route'); +describe('getRawPath', () => { + it('returns the same path if there are no query parameters or hashes', () => { + expect(getRawPath('/home')).to.equal('/home'); + expect(getRawPath('/users/123/profile')).to.equal('/users/123/profile'); + }); + + it('strips query parameters', () => { + expect(getRawPath('/home?foo=bar')).to.equal('/home'); + expect(getRawPath('/home?foo=bar&baz=qux')).to.equal('/home'); + }); + + it('strips hash fragment', () => { + expect(getRawPath('/home#section1')).to.equal('/home'); + }); + + it('strips matrix parameters', () => { + expect(getRawPath('/users;id=123;role=admin/profile')).to.equal( + '/users/profile' + ); + expect( + getRawPath('/users;id=123/profile;tab=posts/details;view=detailed') + ).to.equal('/users/profile/details'); + }); + + it('strips both query parameters and hash fragment', () => { + expect(getRawPath('/home?foo=bar#section1')).to.equal('/home'); + expect(getRawPath('/home#section1?foo=bar')).to.equal('/home'); + }); + + it('strips query parameters, hash fragments, and matrix parameters combined', () => { + expect( + getRawPath('/users;id=123/profile;tab=posts?foo=bar#section1') + ).to.equal('/users/profile'); + }); +}); + +describe('setupNavigationTracking', () => { + let app: FirebaseApp; + let fakeCrashlytics: Crashlytics; + let attributesStore: AttributesStore; + let router: Router; + let destroyRef: DestroyRef; + + beforeEach(() => { + app = initializeApp({ projectId: 'p', appId: 'fakeapp' }); + attributesStore = new AttributesStore(app.options); + fakeCrashlytics = { + attributesStore, + loggerProvider: { + getLogger: () => ({ + emit: () => {} + }) + } + } as unknown as Crashlytics; + + stub(crashlytics, 'getCrashlytics').returns(fakeCrashlytics); + + // Set up a real Angular testing module with routes + TestBed.configureTestingModule({ + imports: [ + RouterModule.forRoot([ + { path: 'home', component: MockComponent }, + { path: 'about', component: MockComponent }, + { path: 'dashboard', component: MockComponent }, + { path: 'users/:id', component: MockComponent } + ]) + ], + providers: [provideZoneChangeDetection()] }); + + router = TestBed.inject(Router); + destroyRef = TestBed.inject(DestroyRef); + }); + + afterEach(async () => { + restore(); + await deleteApp(app); + }); + + it('should register routePath in attributesStore on initialization and clear registration on destruction', async () => { + await router.navigate(['/home']); + setupNavigationTracking(app, router, destroyRef); + + const routePath = + attributesStore.getLogAttributes()[LOG_ATTR_KEY.ROUTE_PATH]; + expect(routePath).to.equal('/home'); + + TestBed.resetTestingModule(); + + const routePathAfterUnmount = + attributesStore.getLogAttributes()[LOG_ATTR_KEY.ROUTE_PATH]; + expect(routePathAfterUnmount).to.be.undefined; + }); + + it('should invoke logViewBoundary on initialization if the router is set up', async () => { + await router.navigate(['/home']); + const logViewBoundaryStub = stub(crashlytics, 'logViewBoundary'); + + setupNavigationTracking(app, router, destroyRef); + + expect(logViewBoundaryStub).to.have.been.calledWith( + fakeCrashlytics, + '/home' + ); + }); + + it('should not invoke logViewBoundary on initialization if the router is not set up yet', async () => { + const logViewBoundaryStub = stub(crashlytics, 'logViewBoundary'); + + setupNavigationTracking(app, router, destroyRef); + + expect(logViewBoundaryStub).to.not.have.been.called; + + await router.navigate(['/home']); + + expect(logViewBoundaryStub).to.have.been.calledWith( + fakeCrashlytics, + '/home' + ); + }); + + it('should invoke logViewBoundary with new route pattern if raw path changes', async () => { + await router.navigate(['/users/123']); + setupNavigationTracking(app, router, destroyRef); + + const logViewBoundaryStub = stub(crashlytics, 'logViewBoundary'); + + await router.navigate(['/users/456']); + expect(logViewBoundaryStub).to.have.been.calledWith( + fakeCrashlytics, + '/users/:id' + ); + }); + + it('should not invoke logViewBoundary if raw path remains the same', async () => { + await router.navigate(['/home']); + setupNavigationTracking(app, router, destroyRef); + + const logViewBoundaryStub = stub(crashlytics, 'logViewBoundary'); + + await router.navigate(['/home']); + + expect(logViewBoundaryStub).to.not.have.been.called; + }); + + it('should set routePath in attributesStore with new route pattern if raw path changes', async () => { + await router.navigate(['/home']); + setupNavigationTracking(app, router, destroyRef); + + await router.navigate(['/about']); + + const routePath = + attributesStore.getLogAttributes()[LOG_ATTR_KEY.ROUTE_PATH]; + expect(routePath).to.equal('/about'); }); }); diff --git a/packages/crashlytics/src/angular/index.ts b/packages/crashlytics/src/angular/index.ts index 5367090418..5cd0689e67 100644 --- a/packages/crashlytics/src/angular/index.ts +++ b/packages/crashlytics/src/angular/index.ts @@ -15,10 +15,16 @@ * limitations under the License. */ -import { ErrorHandler, inject } from '@angular/core'; -import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { ErrorHandler, inject, DestroyRef } from '@angular/core'; +import { + ActivatedRouteSnapshot, + NavigationEnd, + Router, + DefaultUrlSerializer, + UrlSegmentGroup +} from '@angular/router'; import { registerCrashlytics } from '../register'; -import { recordError, getCrashlytics } from '../api'; +import { recordError, getCrashlytics, logViewBoundary } from '../api'; import { Crashlytics, CrashlyticsOptions } from '../public-types'; import { FirebaseApp } from '@firebase/app'; import { CrashlyticsInternal } from '../types'; @@ -27,6 +33,56 @@ registerCrashlytics(); export * from '../public-types'; +/** + * Constructs the safe, templated route path from the router state. + * Example output: '/users/:id/posts' + * + * @internal + */ +export function getSafeRoutePath(router: Router): string { + let currentRoute: ActivatedRouteSnapshot = router.routerState.snapshot.root; + + // Find the deepest activated child route + while (currentRoute.firstChild) { + currentRoute = currentRoute.firstChild; + } + + // Traverse down from the root to the deepest child, collecting configured paths + const pathFromRoot = currentRoute.pathFromRoot + .map(route => route.routeConfig?.path) + .filter(path => path !== undefined && path !== '') // Filter out empty or undefined paths + .join('/'); + + return `/${pathFromRoot}`; +} + +/** + * Recursively traverses the parsed URL segment group tree and clears the parameter map + * of each segment to strip out all Angular matrix parameters (e.g. `;id=123`). + */ +function stripMatrixParams(group: UrlSegmentGroup): void { + for (const segment of group.segments) { + segment.parameters = {}; + } + for (const child of Object.values(group.children)) { + stripMatrixParams(child); + } +} + +/** + * Extracts the raw path portion from a full URL by stripping query parameters, hashes, and matrix parameters. + * + * @internal + */ +export function getRawPath(url: string): string { + const serializer = new DefaultUrlSerializer(); + const urlTree = serializer.parse(url); + urlTree.queryParams = {}; + urlTree.fragment = null; + stripMatrixParams(urlTree.root); + return serializer.serialize(urlTree); +} + /** * A custom ErrorHandler that captures uncaught errors and sends them to Firebase Crashlytics. * @@ -80,34 +136,82 @@ export class FirebaseErrorHandler implements ErrorHandler { this.crashlytics = getCrashlytics(app, crashlyticsOptions); const attributesStore = (this.crashlytics as CrashlyticsInternal) .attributesStore; - attributesStore.setRoutePathProvider(() => - this.getSafeRoutePath(this.router) - ); + attributesStore.setRoutePathProvider(() => getSafeRoutePath(this.router)); } handleError(error: unknown): void { recordError(this.crashlytics, error); } +} - /** - * Constructs the safe, templated route path from the router state. - * Example output: '/users/:id/posts' - */ - private getSafeRoutePath(router: Router): string { - let currentRoute: ActivatedRouteSnapshot | null = - router.routerState.snapshot.root; - - // Find the deepest activated child route - while (currentRoute.firstChild) { - currentRoute = currentRoute.firstChild; - } +/** + * Configures automatic Angular router navigation tracking for Firebase Crashlytics. + * + * This function subscribes to router navigation events, keeps the Crashlytics route path attribute + * updated, and logs view boundary telemetry automatically. + * + * @example + * ```typescript + * import { ApplicationConfig, ErrorHandler, inject, DestroyRef } from '@angular/core'; + * import { Router } from '@angular/router'; + * import { FirebaseErrorHandler, setupNavigationTracking } from '@firebase/crashlytics/angular'; + * + * export const appConfig: ApplicationConfig = { + * providers: [ + * { + * provide: ErrorHandler, + * useFactory: () => new FirebaseErrorHandler(firebaseApp) + * }, + * provideEnvironmentInitializer(() => { + * inject(ErrorHandler); + * setupNavigationTracking( + * firebaseApp, + * inject(Router), + * inject(DestroyRef) + * ); + * }) + * ] + * }; + * ``` + * + * @param app - The {@link @firebase/app#FirebaseApp} instance to use. + * @param router - The Angular {@link @angular/router#Router} instance to subscribe to. + * @param destroyRef - The {@link @angular/core#DestroyRef} instance to bind teardown logic to. + * @param crashlyticsOptions - Optional. {@link CrashlyticsOptions} that configure the Crashlytics instance. + * + * @public + */ +export function setupNavigationTracking( + app: FirebaseApp, + router: Router, + destroyRef: DestroyRef, + crashlyticsOptions?: CrashlyticsOptions +): void { + const crashlytics = getCrashlytics(app, crashlyticsOptions); + const attributesStore = (crashlytics as CrashlyticsInternal).attributesStore; - // Traverse up from the deepest child to the root, collecting configured paths - const pathFromRoot = currentRoute.pathFromRoot - .map(route => route.routeConfig?.path) - .filter(path => path !== undefined && path !== '') // Filter out empty or undefined paths - .join('/'); + attributesStore.setRoutePathProvider(() => getSafeRoutePath(router)); - return `/${pathFromRoot}`; + let lastRawPath: string | undefined = undefined; + if (router.navigated) { + const initialPattern = getSafeRoutePath(router); + lastRawPath = getRawPath(router.url); + logViewBoundary(crashlytics, initialPattern); } + + const subscription = router.events.subscribe(event => { + if (event instanceof NavigationEnd) { + const currentRawPath = getRawPath(event.urlAfterRedirects); + if (currentRawPath !== lastRawPath) { + lastRawPath = currentRawPath; + const pattern = getSafeRoutePath(router); + logViewBoundary(crashlytics, pattern); + } + } + }); + + destroyRef.onDestroy(() => { + attributesStore.setRoutePathProvider(undefined); + subscription.unsubscribe(); + }); } diff --git a/packages/crashlytics/src/next-navigation/index.ts b/packages/crashlytics/src/next-navigation/index.ts index a11cc02a52..5f7c1c8d8e 100644 --- a/packages/crashlytics/src/next-navigation/index.ts +++ b/packages/crashlytics/src/next-navigation/index.ts @@ -34,6 +34,8 @@ export * from '../public-types'; * @example * // pathname = "/users/123/details", params = { id: "123" } * // returns "/users/:id/details" + * + * @internal */ export function getParameterizedRoute( pathname: string | null,