Instrument core-app-api to capture 'navigate' events on location change.

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2021-08-01 16:06:39 +02:00
parent e749a38e89
commit d9fd798cc8
5 changed files with 235 additions and 3 deletions
@@ -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`.
+12
View File
@@ -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;
};
};
/**
+81 -2
View File
@@ -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 <Navigate to="/foo" />;
}),
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(
<Provider>
<Router>
<Routes>
<Route path="/" element={<NavigateComponent />} />
<Route path="/foo" element={<HiddenComponent path="/foo" />} />
</Routes>
</Router>
</Provider>,
);
// 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: [],
+4 -1
View File
@@ -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 (
<RouterComponent>
<RouteTracker objects={createRoutesFromChildren(children)} />
<Routes>
<Route path={mountPath} element={<>{children}</>} />
</Routes>
@@ -376,6 +378,7 @@ export class PrivateAppImpl implements BackstageApp {
return (
<RouterComponent>
<RouteTracker objects={createRoutesFromChildren(children)} />
<SignInPageWrapper component={SignInPageComponent}>
<Routes>
<Route path={mountPath} element={<>{children}</>} />
@@ -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<typeof createRoutesFromChildren>;
/**
* 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 (
<AnalyticsDomain attributes={attributes}>
<CaptureOnRender pathname={pathname} search={search} hash={hash} />
</AnalyticsDomain>
);
};