Merge pull request #17480 from backstage/vinzscam/forward-route-params-to-navigate-event

Forward route params to navigate event
This commit is contained in:
Fredrik Adelöw
2023-05-02 12:08:03 +02:00
committed by GitHub
5 changed files with 161 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': minor
---
The analytics' `navigate` event will now include the route parameters as attributes of the navigate event
+1 -1
View File
@@ -54,7 +54,7 @@ installed, may be captured.
| Action | Subject | Other Notes |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `navigate` | The URL of the page that was navigated to. | |
| `navigate` | The URL of the page that was navigated to. | The parameters of the current route will be included as attributes |
| `click` | The text of the link that was clicked on. | The `to` attribute represents the URL clicked to. |
| `create` | The `name` of the software being created; if no `name` property is requested by the given Software Template, then the string `new {templateName}` is used instead. | The context holds an `entityRef`, set to the template's ref (e.g. `template:default/template-name`). |
| `search` | The search term entered in any search bar component. | The context holds `searchTypes`, representing `types` constraining the search. The `value` represents the total number of search results for the query. This may not be visible if the permission framework is being used. |
@@ -0,0 +1,115 @@
/*
* 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 {
AnalyticsApi,
analyticsApiRef,
createRouteRef,
} from '@backstage/core-plugin-api';
describe('RouteTracker', () => {
const routeRef1 = createRouteRef({
id: 'route1',
});
const routeRef2 = createRouteRef({
id: 'route2',
});
const routeObjects: BackstageRouteObject[] = [
{
path: '/path/:p1/:p2',
element: <Link to="/path2/hello">go</Link>,
routeRefs: new Set([routeRef1]),
caseSensitive: false,
},
{
path: '/path2/:param',
element: <div>hi there</div>,
routeRefs: new Set([routeRef2]),
caseSensitive: false,
},
];
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: 'root',
routeRef: 'route1',
},
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(({ routeRefs, ...props }) => (
<Route {...props} key={props.path} />
))}
</Routes>
</TestApiProvider>
</MemoryRouter>,
);
fireEvent.click(getByText('go'));
expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
action: 'navigate',
attributes: {
param: 'hello',
},
context: {
extension: 'App',
pluginId: 'root',
routeRef: 'route2',
},
subject: '/path2/hello',
value: undefined,
});
});
});
@@ -19,8 +19,8 @@ import { matchRoutes, useLocation } from 'react-router-dom';
import {
useAnalytics,
AnalyticsContext,
CommonAnalyticsContext,
RouteRef,
AnalyticsEventAttributes,
} from '@backstage/core-plugin-api';
import { BackstageRouteObject } from './types';
@@ -31,22 +31,23 @@ import { BackstageRouteObject } from './types';
const getExtensionContext = (
pathname: string,
routes: BackstageRouteObject[],
): CommonAnalyticsContext | {} => {
) => {
try {
// Find matching routes for the given path name.
const matches = matchRoutes(routes, { pathname }) as
| { route: BackstageRouteObject }[]
| null;
const matches = matchRoutes(routes, { pathname });
// Of the matching routes, get the last (e.g. most specific) instance of
// the BackstageRouteObject.
const routeObject = matches
const routeMatch = matches
?.filter(match => match?.route.routeRefs?.size > 0)
.pop()?.route;
.pop();
const routeObject = routeMatch?.route;
// If there is no route object, then allow inheritance of default context.
if (!routeObject) {
return {};
return undefined;
}
// If there is a single route ref, return it.
@@ -56,13 +57,23 @@ const getExtensionContext = (
routeRef = routeObject.routeRefs.values().next().value;
}
const params = Object.entries(
routeMatch?.params || {},
).reduce<AnalyticsEventAttributes>((acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
}, {});
return {
extension: 'App',
pluginId: routeObject.plugin?.getId() || 'root',
...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}),
params,
};
} catch {
return {};
return undefined;
}
};
@@ -73,16 +84,19 @@ const TrackNavigation = ({
pathname,
search,
hash,
attributes,
}: {
pathname: string;
search: string;
hash: string;
attributes?: AnalyticsEventAttributes;
}) => {
const analytics = useAnalytics();
useEffect(() => {
analytics.captureEvent('navigate', `${pathname}${search}${hash}`);
}, [analytics, pathname, search, hash]);
analytics.captureEvent('navigate', `${pathname}${search}${hash}`, {
attributes,
});
}, [analytics, pathname, search, hash, attributes]);
return null;
};
@@ -98,9 +112,19 @@ export const RouteTracker = ({
}) => {
const { pathname, search, hash } = useLocation();
const { params, ...attributes } = getExtensionContext(
pathname,
routeObjects,
) || { params: {} };
return (
<AnalyticsContext attributes={getExtensionContext(pathname, routeObjects)}>
<TrackNavigation pathname={pathname} search={search} hash={hash} />
<AnalyticsContext attributes={attributes}>
<TrackNavigation
pathname={pathname}
search={search}
hash={hash}
attributes={params}
/>
</AnalyticsContext>
);
};
@@ -53,6 +53,8 @@ export function useAnalytics(): AnalyticsTracker {
}
const tracker = getTracker();
// this is not ideal, but it allows to memoize the tracker
// without explicitly set the context as dependency.
tracker.setContext(context);
return tracker;