feat: use analytics tracker on routing provider

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2023-11-08 14:09:33 +01:00
parent 6e32443ebd
commit 9f07394a99
3 changed files with 371 additions and 0 deletions
@@ -0,0 +1,228 @@
/*
* Copyright 2023 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 { TestApiProvider } from '@backstage/test-utils';
import React from 'react';
import { BackstageRouteObject } from './types';
import { fireEvent, render } from '@testing-library/react';
import { RouteTracker } from './RouteTracker';
import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
import { createPlugin } from '@backstage/core-plugin-api';
import {
createRouteRef,
AnalyticsApi,
analyticsApiRef,
} from '@backstage/frontend-plugin-api';
import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode';
describe('RouteTracker', () => {
const routeRef0 = createRouteRef();
const routeRef1 = createRouteRef();
const routeRef2 = createRouteRef();
const plugin0 = createPlugin({ id: 'home' });
const plugin1 = createPlugin({ id: 'plugin1' });
const plugin2 = createPlugin({ id: 'plugin2' });
const routeObjects: BackstageRouteObject[] = [
{
path: '',
element: <div>home page</div>,
routeRefs: new Set([routeRef0]),
plugins: new Set([plugin0]),
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
},
{
path: '/path/:p1/:p2',
element: <Link to="/path2/hello">go</Link>,
routeRefs: new Set([routeRef1]),
plugins: new Set([plugin1]),
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
},
{
path: '/path2/:param',
element: <div>hi there</div>,
routeRefs: new Set([routeRef2]),
plugins: new Set([plugin2]),
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
},
];
const mockedAnalytics: jest.Mocked<AnalyticsApi> = {
captureEvent: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should capture the navigate event on load', async () => {
render(
<MemoryRouter initialEntries={['/path/foo/bar']}>
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
<RouteTracker routeObjects={routeObjects} />
</TestApiProvider>
</MemoryRouter>,
);
expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
action: 'navigate',
attributes: {
p1: 'foo',
p2: 'bar',
},
context: {
extension: 'App',
pluginId: 'plugin1',
routeRef: undefined,
},
subject: '/path/foo/bar',
value: undefined,
});
});
it('should capture the navigate event on route change', async () => {
const { getByText } = render(
<MemoryRouter initialEntries={['/path/foo/bar']}>
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
<RouteTracker routeObjects={routeObjects} />
<Routes>
{routeObjects.map(({ path, element }) => (
<Route key={path} path={path || '/'} element={element} />
))}
</Routes>
</TestApiProvider>
</MemoryRouter>,
);
fireEvent.click(getByText('go'));
expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
action: 'navigate',
attributes: {
param: 'hello',
},
context: {
extension: 'App',
pluginId: 'plugin2',
routeRef: undefined,
},
subject: '/path2/hello',
value: undefined,
});
});
it('should capture path query and hash', async () => {
render(
<MemoryRouter initialEntries={['/path/foo/bar?q=1#header-1']}>
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
<RouteTracker routeObjects={routeObjects} />
</TestApiProvider>
</MemoryRouter>,
);
expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
action: 'navigate',
attributes: {
p1: 'foo',
p2: 'bar',
},
context: {
extension: 'App',
pluginId: 'plugin1',
routeRef: undefined,
},
subject: '/path/foo/bar?q=1#header-1',
value: undefined,
});
});
it('should match the root path and send relevant context', async () => {
render(
<MemoryRouter initialEntries={['/']}>
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
<RouteTracker routeObjects={routeObjects} />
</TestApiProvider>
</MemoryRouter>,
);
expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
action: 'navigate',
attributes: {},
context: {
extension: 'App',
pluginId: 'home',
routeRef: undefined,
},
subject: '/',
value: undefined,
});
});
it('should return default context when it would have otherwise matched on the root path', async () => {
render(
<MemoryRouter initialEntries={['/not-routable-extension']}>
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
<RouteTracker routeObjects={routeObjects} />
<Routes>
<Route
path="/not-routable-extension"
element={<>Non-extension</>}
/>
</Routes>
</TestApiProvider>
</MemoryRouter>,
);
expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
action: 'navigate',
attributes: {},
context: {
extension: 'App',
pluginId: 'root',
routeRef: 'unknown',
},
subject: '/not-routable-extension',
value: undefined,
});
});
it('should return parent route context on navigating to a sub-route', async () => {
render(
<MemoryRouter initialEntries={['/path2/param-value/sub-route']}>
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
<RouteTracker routeObjects={routeObjects} />
</TestApiProvider>
</MemoryRouter>,
);
expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
action: 'navigate',
attributes: {
param: 'param-value',
},
context: {
extension: 'App',
pluginId: 'plugin2',
routeRef: undefined,
},
subject: '/path2/param-value/sub-route',
value: undefined,
});
});
});
@@ -0,0 +1,141 @@
/*
* Copyright 2023 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 { matchRoutes, useLocation } from 'react-router-dom';
import { RouteRef, BackstagePlugin } from '@backstage/core-plugin-api';
import {
useAnalytics,
AnalyticsContext,
AnalyticsEventAttributes,
} from '@backstage/core-plugin-api';
import { BackstageRouteObject } from './types';
/**
* Returns an extension context given the current pathname and a list of
* Backstage route objects.
*/
const getExtensionContext = (
pathname: string,
routes: BackstageRouteObject[],
) => {
try {
// Find matching routes for the given path name.
const matches = matchRoutes(routes, { pathname });
// Of the matching routes, get the last (e.g. most specific) instance of
// the BackstageRouteObject that contains a routeRef. Filtering by routeRef
// ensures subRouteRefs are aligned to their parent routes' context.
const routeMatch = matches
?.filter(match => match?.route.routeRefs?.size > 0)
.pop();
const routeObject = routeMatch?.route;
// If there is no route object, then allow inheritance of default context.
if (!routeObject) {
return undefined;
}
// If the matched route is the root route (no path), and the pathname is
// not the path of the homepage, then inherit from the default context.
if (routeObject.path === '' && pathname !== '/') {
return undefined;
}
// If there is a single route ref, use it.
let routeRef: RouteRef | undefined;
if (routeObject.routeRefs.size === 1) {
routeRef = routeObject.routeRefs.values().next().value;
}
// If there is a single plugin, use it.
let plugin: BackstagePlugin | undefined;
if (routeObject.plugins.size === 1) {
plugin = routeObject.plugins.values().next().value;
}
const params = Object.entries(
routeMatch?.params || {},
).reduce<AnalyticsEventAttributes>((acc, [key, value]) => {
if (value !== undefined && key !== '*') {
acc[key] = value;
}
return acc;
}, {});
return {
extension: 'App',
pluginId: plugin?.getId() || 'root',
...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}),
_routeNodeType: routeObject.element as string,
params,
};
} catch {
return undefined;
}
};
/**
* Performs the actual event capture on render.
*/
const TrackNavigation = ({
pathname,
search,
hash,
attributes,
}: {
pathname: string;
search: string;
hash: string;
attributes?: AnalyticsEventAttributes;
}) => {
const analytics = useAnalytics();
useEffect(() => {
analytics.captureEvent('navigate', `${pathname}${search}${hash}`, {
attributes,
});
}, [analytics, pathname, search, hash, attributes]);
return null;
};
/**
* Logs a "navigate" event with appropriate plugin-level analytics context
* attributes each time the user navigates to a page.
*/
export const RouteTracker = ({
routeObjects,
}: {
routeObjects: BackstageRouteObject[];
}) => {
const { pathname, search, hash } = useLocation();
const { params, ...attributes } = getExtensionContext(
pathname,
routeObjects,
) || { params: {} };
return (
<AnalyticsContext attributes={attributes}>
<TrackNavigation
pathname={pathname}
search={search}
hash={hash}
attributes={params}
/>
</AnalyticsContext>
);
};
@@ -97,6 +97,7 @@ import { CoreRouter } from '../extensions/CoreRouter';
import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createPlugin';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
import { RouteTracker } from '../routing/RouteTracker';
const builtinExtensions = [
Core,
@@ -342,6 +343,7 @@ export function createSpecializedApp(options?: {
<AppThemeProvider>
<RoutingProvider {...routeInfo} routeBindings={routeBindings}>
<InternalAppContext.Provider value={{ appIdentityProxy }}>
<RouteTracker routeObjects={routeInfo.routeObjects} />
{rootEl}
</InternalAppContext.Provider>
</RoutingProvider>