diff --git a/.changeset/analytics-sings-stormy-weather.md b/.changeset/analytics-sings-stormy-weather.md new file mode 100644 index 0000000000..2a0d83e8a4 --- /dev/null +++ b/.changeset/analytics-sings-stormy-weather.md @@ -0,0 +1,24 @@ +--- +'@backstage/core-app-api': patch +--- + +The Core App API now automatically instruments all route location changes using +the new Analytics API. Each location change triggers a `navigate` event, which +is an analogue of a "pageview" event in traditional web analytics systems. In +addition to the path, these events provide plugin-level metadata via the +analytics domain, which can be useful for analyzing plugin usage: + +```json +{ + "verb": "navigate", + "noun": "/the-path/navigated/to?with=params#and-hashes", + "domain": { + "componentName": "App", + "pluginId": "id-of-plugin-that-exported-the-route", + "routeRef": "associated-route-ref-id" + } +} +``` + +These events can be identified and handled by checking for the verb `navigate` +and the componentName `App`. diff --git a/packages/core-app-api/config.d.ts b/packages/core-app-api/config.d.ts index d88c818d11..a2fa3f1989 100644 --- a/packages/core-app-api/config.d.ts +++ b/packages/core-app-api/config.d.ts @@ -65,6 +65,18 @@ export interface Config { }>; }>; }; + + /** + * Information about how analytics events should be collected in this + * Backstage Instance. + */ + analytics?: { + /** + * The provider used to collect instrumented events. Further + * configuration values depend on the provider specified here. + */ + provider: string; + }; }; /** diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/App.test.tsx index 6fc1d96fd8..93b711036b 100644 --- a/packages/core-app-api/src/app/App.test.tsx +++ b/packages/core-app-api/src/app/App.test.tsx @@ -15,11 +15,15 @@ */ import { LocalStorageFeatureFlags } from '../apis'; -import { renderWithEffects, withLogCollector } from '@backstage/test-utils'; +import { + MockAnalyticsApi, + renderWithEffects, + withLogCollector, +} from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { render, screen } from '@testing-library/react'; import React, { PropsWithChildren } from 'react'; -import { BrowserRouter, Routes } from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { defaultAppIcons } from './icons'; import { configApiRef, @@ -31,6 +35,7 @@ import { createRouteRef, createSubRouteRef, createRoutableExtension, + analyticsApiRef, } from '@backstage/core-plugin-api'; import { generateBoundRoutes, PrivateAppImpl } from './App'; import { AppThemeProvider } from './AppThemeProvider'; @@ -59,6 +64,7 @@ describe('generateBoundRoutes', () => { describe('Integration Test', () => { const plugin1RouteRef = createRouteRef({ id: 'ref-1' }); + const plugin1RouteRef2 = createRouteRef({ id: 'ref-1-2' }); const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] }); const subRouteRef1 = createSubRouteRef({ id: 'sub1', @@ -155,6 +161,16 @@ describe('Integration Test', () => { }), ); + const NavigateComponent = plugin1.provide( + createRoutableExtension({ + component: () => + Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { + return ; + }), + mountPoint: plugin1RouteRef2, + }), + ); + const components = { NotFoundErrorPage: () => null, BootErrorPage: () => null, @@ -322,6 +338,69 @@ describe('Integration Test', () => { }); }); + it('should track route changes via analytics api', async () => { + const mockAnalyticsApi = new MockAnalyticsApi(); + const apis = [createApiFactory(analyticsApiRef, mockAnalyticsApi)]; + const app = new PrivateAppImpl({ + apis, + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultAppIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + } /> + } /> + + + , + ); + + // Capture initial and subsequent navigation events with expected domain + // values. + const capturedEvents = mockAnalyticsApi.getEvents(); + expect(capturedEvents[0]).toMatchObject({ + verb: 'navigate', + noun: '/', + domain: { + componentName: 'App', + pluginId: 'blob', + routeRef: 'ref-1-2', + }, + }); + expect(capturedEvents[1]).toMatchObject({ + verb: 'navigate', + noun: '/foo', + domain: { + componentName: 'App', + pluginId: 'plugin2', + routeRef: 'ref-2', + }, + }); + expect(capturedEvents).toHaveLength(2); + }); + it('should throw some error when the route has duplicate params', () => { const app = new PrivateAppImpl({ apis: [], diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx index f5fec7e624..fb40a83dab 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/App.tsx @@ -23,7 +23,7 @@ import React, { useMemo, useState, } from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { createRoutesFromChildren, Route, Routes } from 'react-router-dom'; import { useAsync } from 'react-use'; import { ApiProvider, @@ -63,6 +63,7 @@ import { routePathCollector, } from '../routing/collectors'; import { RoutingProvider } from '../routing/RoutingProvider'; +import { RouteTracker } from '../routing/RouteTracker'; import { validateRoutes } from '../routing/validation'; import { AppContextProvider } from './AppContext'; import { AppIdentity } from './AppIdentity'; @@ -367,6 +368,7 @@ export class PrivateAppImpl implements BackstageApp { return ( + {children}} /> @@ -376,6 +378,7 @@ export class PrivateAppImpl implements BackstageApp { return ( + {children}} /> diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx new file mode 100644 index 0000000000..64e7643762 --- /dev/null +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, +} from 'react-router-dom'; +import { + BackstagePlugin, + useAnalytics, + getComponentData, + AnalyticsDomain, + RoutableAnalyticsDomain, +} from '@backstage/core-plugin-api'; + +type RouteObjects = ReturnType; + +/** + * Returns an extension domain given the current pathname and a RouteObject + * that defines all registered routes in react. + * + * If no exact match is found, path parts are stripped away, one-by-one, until + * a parent-level path matches a route. + */ +const getExtensionDomain = ( + pathname: string, + routes: RouteObjects, +): RoutableAnalyticsDomain | {} => { + const cleanPath = pathname.replace(/\/+$/, ''); + const matches = matchRoutes(routes, { pathname }); + const RouteElement = matches + ?.filter(match => { + const pathsMatch = match.pathname.replace(/\/+$/, '') === cleanPath; + const hasRoutableElement = !!(match.route.element as React.ReactElement) + ?.props?.element; + return pathsMatch && hasRoutableElement; + }) + .pop()?.route?.element; + const RoutableElement = (RouteElement as React.ReactElement)?.props?.element; + + if (RoutableElement) { + const plugin: BackstagePlugin | undefined = getComponentData( + RoutableElement, + 'core.plugin', + ); + const mountPoint: { id?: string } | undefined = getComponentData( + RoutableElement, + 'core.mountPoint', + ); + if (plugin && mountPoint) { + return { + pluginId: plugin.getId(), + componentName: 'App', + routeRef: mountPoint?.id || '', + }; + } + } + + // Try again, one path-level shallower. + const nextLevelPath = cleanPath.split('/').slice(0, -1).join('/'); + return nextLevelPath !== '' ? getExtensionDomain(nextLevelPath, routes) : {}; +}; + +/** + * Performs the actual event capture on render. + */ +const CaptureOnRender = ({ + pathname, + search, + hash, +}: { + pathname: string; + search: string; + hash: string; +}) => { + const analytics = useAnalytics(); + + useEffect(() => { + analytics.captureEvent('navigate', `${pathname}${search}${hash}`); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname, search, hash]); + + return null; +}; + +/** + * Logs a "navigate" event with appropriate plugin-level analytics domain + * attributes each time the user navigates to a page. + */ +export const RouteTracker = ({ objects }: { objects: RouteObjects }) => { + const { pathname, search, hash } = useLocation(); + const attributes = getExtensionDomain(pathname, objects); + + return ( + + + + ); +};