feat: use analytics tracker on routing provider
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user