Instrument core-app-api to capture 'navigate' events on location change.
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
@@ -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`.
|
||||
Vendored
+12
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user