Merge pull request #21244 from backstage/camilaibs/migrate-di-analytics-context
[DI] Migrate Analytics Context Files
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Migrate analytics route tracker component.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
---
|
||||
|
||||
Migrate analytics api and context files.
|
||||
@@ -33,6 +33,7 @@ import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { signInPageComponentDataRef } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension';
|
||||
import { RouteTracker } from '../routing/RouteTracker';
|
||||
|
||||
export const CoreRouter = createExtension({
|
||||
id: 'core.router',
|
||||
@@ -132,7 +133,7 @@ export function AppRouter(props: AppRouterProps) {
|
||||
if (!internalAppContext) {
|
||||
throw new Error('AppRouter must be rendered within the AppProvider');
|
||||
}
|
||||
const { appIdentityProxy } = internalAppContext;
|
||||
const { routeObjects, appIdentityProxy } = internalAppContext;
|
||||
|
||||
// If the app hasn't configured a sign-in page, we just continue as guest.
|
||||
if (!SignInPageComponent) {
|
||||
@@ -159,11 +160,17 @@ export function AppRouter(props: AppRouterProps) {
|
||||
{ signOutTargetUrl: basePath || '/' },
|
||||
);
|
||||
|
||||
return <BrowserRouter basename={basePath}>{children}</BrowserRouter>;
|
||||
return (
|
||||
<BrowserRouter basename={basePath}>
|
||||
<RouteTracker routeObjects={routeObjects} />
|
||||
{children}
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter basename={basePath}>
|
||||
<RouteTracker routeObjects={routeObjects} />
|
||||
<SignInPageWrapper
|
||||
component={SignInPageComponent}
|
||||
appIdentityProxy={appIdentityProxy}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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, { useEffect } 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,
|
||||
AppNode,
|
||||
useAnalytics,
|
||||
} 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],
|
||||
appNode: {
|
||||
spec: { extension: { id: 'home.page.index' }, source: { id: 'home' } },
|
||||
} as AppNode,
|
||||
},
|
||||
{
|
||||
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],
|
||||
appNode: {
|
||||
spec: {
|
||||
extension: { id: 'plugin1.page.index' },
|
||||
source: { id: 'plugin1' },
|
||||
},
|
||||
} as AppNode,
|
||||
},
|
||||
{
|
||||
path: '/path2/:param',
|
||||
element: <div>hi there</div>,
|
||||
routeRefs: new Set([routeRef2]),
|
||||
plugins: new Set([plugin2]),
|
||||
caseSensitive: false,
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
appNode: {
|
||||
spec: {
|
||||
extension: { id: 'plugin2.page.index' },
|
||||
source: { id: 'plugin2' },
|
||||
},
|
||||
} as AppNode,
|
||||
},
|
||||
];
|
||||
|
||||
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: {
|
||||
extensionId: 'plugin1.page.index',
|
||||
pluginId: 'plugin1',
|
||||
},
|
||||
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: {
|
||||
extensionId: 'plugin2.page.index',
|
||||
pluginId: 'plugin2',
|
||||
},
|
||||
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: {
|
||||
extensionId: 'plugin1.page.index',
|
||||
pluginId: 'plugin1',
|
||||
},
|
||||
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: {
|
||||
extensionId: 'home.page.index',
|
||||
pluginId: 'home',
|
||||
},
|
||||
subject: '/',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return default context when it would have otherwise matched on the root path', async () => {
|
||||
const Dummy = () => {
|
||||
const analytics = useAnalytics();
|
||||
useEffect(() => {
|
||||
analytics.captureEvent('click', 'test', {});
|
||||
}, [analytics]);
|
||||
return <div>dummy</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/not-routable-extension']}>
|
||||
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
|
||||
<RouteTracker routeObjects={routeObjects} />
|
||||
<Routes>
|
||||
<Route path="/not-routable-extension" element={<Dummy />} />
|
||||
</Routes>
|
||||
</TestApiProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(mockedAnalytics.captureEvent).toHaveBeenNthCalledWith(1, {
|
||||
action: 'navigate',
|
||||
attributes: {},
|
||||
context: {
|
||||
extensionId: 'App',
|
||||
pluginId: 'root',
|
||||
},
|
||||
subject: '/not-routable-extension',
|
||||
value: undefined,
|
||||
});
|
||||
expect(mockedAnalytics.captureEvent).toHaveBeenNthCalledWith(2, {
|
||||
action: 'click',
|
||||
attributes: undefined,
|
||||
context: {
|
||||
extensionId: 'App',
|
||||
pluginId: 'root',
|
||||
},
|
||||
subject: 'test',
|
||||
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: {
|
||||
extensionId: 'plugin2.page.index',
|
||||
pluginId: 'plugin2',
|
||||
},
|
||||
subject: '/path2/param-value/sub-route',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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 {
|
||||
useAnalytics,
|
||||
AnalyticsContext,
|
||||
AnalyticsEventAttributes,
|
||||
} from '@backstage/frontend-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;
|
||||
}
|
||||
|
||||
const params = Object.entries(
|
||||
routeMatch?.params || {},
|
||||
).reduce<AnalyticsEventAttributes>((acc, [key, value]) => {
|
||||
if (value !== undefined && key !== '*') {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const plugin = routeObject.appNode?.spec.source;
|
||||
const extension = routeObject.appNode?.spec.extension;
|
||||
|
||||
return {
|
||||
params,
|
||||
pluginId: plugin?.id || 'root',
|
||||
extensionId: extension?.id || 'App',
|
||||
};
|
||||
} 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>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { extractRouteInfoFromAppNode } from './extractRouteInfoFromAppNode';
|
||||
import {
|
||||
AnyRouteRefParams,
|
||||
AppNode,
|
||||
Extension,
|
||||
RouteRef,
|
||||
coreExtensionData,
|
||||
@@ -99,8 +100,10 @@ function routeObj(
|
||||
children: any[] = [],
|
||||
type: 'mounted' | 'gathered' = 'mounted',
|
||||
backstagePlugin?: BackstagePlugin,
|
||||
appNode?: AppNode,
|
||||
) {
|
||||
return {
|
||||
appNode,
|
||||
path: path,
|
||||
caseSensitive: false,
|
||||
element: type,
|
||||
@@ -171,7 +174,14 @@ describe('discovery', () => {
|
||||
[ref5, ref1],
|
||||
]);
|
||||
expect(info.routeObjects).toEqual([
|
||||
routeObj('nothing', []),
|
||||
routeObj(
|
||||
'nothing',
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
),
|
||||
routeObj(
|
||||
'foo',
|
||||
[ref1],
|
||||
@@ -179,16 +189,41 @@ describe('discovery', () => {
|
||||
routeObj(
|
||||
'bar/:id',
|
||||
[ref2],
|
||||
[routeObj('baz', [ref3], undefined, undefined, expect.any(Object))],
|
||||
[
|
||||
routeObj(
|
||||
'baz',
|
||||
[ref3],
|
||||
undefined,
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
routeObj(
|
||||
'blop',
|
||||
[ref5],
|
||||
undefined,
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
routeObj('blop', [ref5], undefined, undefined, expect.any(Object)),
|
||||
],
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
routeObj(
|
||||
'divsoup',
|
||||
[ref4],
|
||||
undefined,
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
routeObj('divsoup', [ref4], undefined, undefined, expect.any(Object)),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -349,13 +384,30 @@ describe('discovery', () => {
|
||||
[ref5, ref3],
|
||||
]);
|
||||
expect(info.routeObjects).toEqual([
|
||||
routeObj('foo', [ref1, ref2], [], 'mounted', expect.any(Object)),
|
||||
routeObj(
|
||||
'foo',
|
||||
[ref1, ref2],
|
||||
[],
|
||||
'mounted',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
routeObj(
|
||||
'bar',
|
||||
[ref3],
|
||||
[routeObj('', [ref4, ref5], [], 'mounted', expect.any(Object))],
|
||||
[
|
||||
routeObj(
|
||||
'',
|
||||
[ref4, ref5],
|
||||
[],
|
||||
'mounted',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
],
|
||||
'mounted',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
]);
|
||||
});
|
||||
@@ -429,18 +481,22 @@ describe('discovery', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
],
|
||||
'mounted',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
]);
|
||||
});
|
||||
@@ -499,7 +555,14 @@ describe('discovery', () => {
|
||||
'r',
|
||||
[],
|
||||
[
|
||||
routeObj('x', [ref1], [], 'mounted', expect.any(Object)),
|
||||
routeObj(
|
||||
'x',
|
||||
[ref1],
|
||||
[],
|
||||
'mounted',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
routeObj(
|
||||
'y',
|
||||
[],
|
||||
@@ -514,6 +577,7 @@ describe('discovery', () => {
|
||||
undefined,
|
||||
'mounted',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
routeObj(
|
||||
'b',
|
||||
@@ -521,15 +585,22 @@ describe('discovery', () => {
|
||||
undefined,
|
||||
'mounted',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
],
|
||||
'mounted',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
],
|
||||
'mounted',
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -84,6 +84,7 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
|
||||
caseSensitive: false,
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugins: new Set(),
|
||||
appNode: current,
|
||||
};
|
||||
parentChildren.push(currentObj);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
AppNode,
|
||||
ExternalRouteRef,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
@@ -35,4 +36,5 @@ export interface BackstageRouteObject {
|
||||
path: string;
|
||||
routeRefs: Set<RouteRef>;
|
||||
plugins: Set<LegacyBackstagePlugin>;
|
||||
appNode?: AppNode;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
import { createContext } from 'react';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
|
||||
import { BackstageRouteObject } from '../routing/types';
|
||||
|
||||
export const InternalAppContext = createContext<
|
||||
| undefined
|
||||
| {
|
||||
appIdentityProxy: AppIdentityProxy;
|
||||
routeObjects: BackstageRouteObject[];
|
||||
}
|
||||
>(undefined);
|
||||
|
||||
@@ -341,7 +341,9 @@ export function createSpecializedApp(options?: {
|
||||
<AppContextProvider appContext={appContext}>
|
||||
<AppThemeProvider>
|
||||
<RoutingProvider {...routeInfo} routeBindings={routeBindings}>
|
||||
<InternalAppContext.Provider value={{ appIdentityProxy }}>
|
||||
<InternalAppContext.Provider
|
||||
value={{ appIdentityProxy, routeObjects: routeInfo.routeObjects }}
|
||||
>
|
||||
{rootEl}
|
||||
</InternalAppContext.Provider>
|
||||
</RoutingProvider>
|
||||
|
||||
@@ -86,6 +86,51 @@ export { alertApiRef };
|
||||
|
||||
export { AlertMessage };
|
||||
|
||||
// @public
|
||||
export type AnalyticsApi = {
|
||||
captureEvent(event: AnalyticsEvent): void;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const analyticsApiRef: ApiRef<AnalyticsApi>;
|
||||
|
||||
// @public
|
||||
export const AnalyticsContext: (options: {
|
||||
attributes: Partial<AnalyticsContextValue>;
|
||||
children: ReactNode;
|
||||
}) => React_2.JSX.Element;
|
||||
|
||||
// @public
|
||||
export type AnalyticsContextValue = CommonAnalyticsContext & {
|
||||
[param in string]: string | boolean | number | undefined;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AnalyticsEvent = {
|
||||
action: string;
|
||||
subject: string;
|
||||
value?: number;
|
||||
attributes?: AnalyticsEventAttributes;
|
||||
context: AnalyticsContextValue;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AnalyticsEventAttributes = {
|
||||
[attribute in string]: string | boolean | number;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AnalyticsTracker = {
|
||||
captureEvent: (
|
||||
action: string,
|
||||
subject: string,
|
||||
options?: {
|
||||
value?: number;
|
||||
attributes?: AnalyticsEventAttributes;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
|
||||
export { AnyApiFactory };
|
||||
|
||||
export { AnyApiRef };
|
||||
@@ -233,6 +278,12 @@ export { bitbucketAuthApiRef };
|
||||
|
||||
export { bitbucketServerAuthApiRef };
|
||||
|
||||
// @public
|
||||
export type CommonAnalyticsContext = {
|
||||
pluginId: string;
|
||||
extensionId: string;
|
||||
};
|
||||
|
||||
export { ConfigApi };
|
||||
|
||||
export { configApiRef };
|
||||
@@ -770,6 +821,9 @@ export interface SubRouteRef<
|
||||
|
||||
export { TypesToApiRefs };
|
||||
|
||||
// @public
|
||||
export function useAnalytics(): AnalyticsTracker;
|
||||
|
||||
export { useApi };
|
||||
|
||||
export { useApiHolder };
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { AnalyticsContext, useAnalyticsContext } from './AnalyticsContext';
|
||||
|
||||
const AnalyticsSpy = () => {
|
||||
const context = useAnalyticsContext();
|
||||
return (
|
||||
<>
|
||||
<div data-testid="plugin-id">{context.pluginId}</div>
|
||||
<div data-testid="extension-id">{context.extensionId}</div>
|
||||
<div data-testid="custom">{context.custom}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AnalyticsContext', () => {
|
||||
describe('useAnalyticsContext', () => {
|
||||
it('returns default values', () => {
|
||||
const { result } = renderHook(() => useAnalyticsContext());
|
||||
expect(result.current).toEqual({
|
||||
extensionId: 'App',
|
||||
pluginId: 'root',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnalyticsContext', () => {
|
||||
it('uses default analytics context', () => {
|
||||
const result = render(
|
||||
<AnalyticsContext attributes={{}}>
|
||||
<AnalyticsSpy />
|
||||
</AnalyticsContext>,
|
||||
);
|
||||
|
||||
expect(result.getByTestId('extension-id')).toHaveTextContent('App');
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('root');
|
||||
});
|
||||
|
||||
it('uses provided analytics context', () => {
|
||||
const result = render(
|
||||
<AnalyticsContext attributes={{ pluginId: 'custom' }}>
|
||||
<AnalyticsSpy />
|
||||
</AnalyticsContext>,
|
||||
);
|
||||
|
||||
expect(result.getByTestId('extension-id')).toHaveTextContent('App');
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
|
||||
});
|
||||
|
||||
it('uses nested analytics context', () => {
|
||||
const result = render(
|
||||
<AnalyticsContext attributes={{ pluginId: 'custom' }}>
|
||||
<AnalyticsContext attributes={{ extensionId: 'AnalyticsSpy' }}>
|
||||
<AnalyticsSpy />
|
||||
</AnalyticsContext>
|
||||
</AnalyticsContext>,
|
||||
);
|
||||
|
||||
expect(result.getByTestId('extension-id')).toHaveTextContent(
|
||||
'AnalyticsSpy',
|
||||
);
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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 {
|
||||
createVersionedContext,
|
||||
createVersionedValueMap,
|
||||
} from '@backstage/version-bridge';
|
||||
import React, { ReactNode, useContext } from 'react';
|
||||
import { AnalyticsContextValue } from './types';
|
||||
|
||||
const AnalyticsReactContext = createVersionedContext<{
|
||||
1: AnalyticsContextValue;
|
||||
}>('analytics-context');
|
||||
|
||||
/**
|
||||
* A "private" (to this package) hook that enables context inheritance and a
|
||||
* way to read Analytics Context values at event capture-time.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const useAnalyticsContext = (): AnalyticsContextValue => {
|
||||
const theContext = useContext(AnalyticsReactContext);
|
||||
|
||||
// Provide a default value if no value exists.
|
||||
if (theContext === undefined) {
|
||||
return {
|
||||
pluginId: 'root',
|
||||
extensionId: 'App',
|
||||
};
|
||||
}
|
||||
|
||||
// This should probably never happen, but check for it.
|
||||
const theValue = theContext.atVersion(1);
|
||||
if (theValue === undefined) {
|
||||
throw new Error('No context found for version 1.');
|
||||
}
|
||||
|
||||
return theValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides components in the child react tree an Analytics Context, ensuring
|
||||
* all analytics events captured within the context have relevant attributes.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Analytics contexts are additive, meaning the context ultimately emitted with
|
||||
* an event is the combination of all contexts in the parent tree.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const AnalyticsContext = (options: {
|
||||
attributes: Partial<AnalyticsContextValue>;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const { attributes, children } = options;
|
||||
|
||||
const parentValues = useAnalyticsContext();
|
||||
const combinedValue = {
|
||||
...parentValues,
|
||||
...attributes,
|
||||
};
|
||||
|
||||
const versionedCombinedValue = createVersionedValueMap({ 1: combinedValue });
|
||||
return (
|
||||
<AnalyticsReactContext.Provider value={versionedCombinedValue}>
|
||||
{children}
|
||||
</AnalyticsReactContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an HOC wrapping the provided component in an Analytics context with
|
||||
* the given values.
|
||||
*
|
||||
* @param Component - Component to be wrapped with analytics context attributes
|
||||
* @param values - Analytics context key/value pairs.
|
||||
* @internal
|
||||
*/
|
||||
export function withAnalyticsContext<TProps extends {}>(
|
||||
Component: React.ComponentType<TProps>,
|
||||
values: AnalyticsContextValue,
|
||||
) {
|
||||
const ComponentWithAnalyticsContext = (props: TProps) => {
|
||||
return (
|
||||
<AnalyticsContext attributes={values}>
|
||||
<Component {...props} />
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
ComponentWithAnalyticsContext.displayName = `WithAnalyticsContext(${
|
||||
Component.displayName || Component.name || 'Component'
|
||||
})`;
|
||||
return ComponentWithAnalyticsContext;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* 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 { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
|
||||
import { Tracker, routableExtensionRenderedEvent } from './Tracker';
|
||||
|
||||
describe('Tracker', () => {
|
||||
const defaultContext = {
|
||||
pluginId: 'root',
|
||||
extensionId: 'App',
|
||||
};
|
||||
const globalEvents = getOrCreateGlobalSingleton<any>(
|
||||
'core-plugin-api:analytics-tracker-events',
|
||||
() => ({}),
|
||||
);
|
||||
const analyticsApiSpy = {
|
||||
captureEvent: jest.fn(),
|
||||
};
|
||||
let trackerUnderTest: Tracker;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks and global state
|
||||
jest.resetAllMocks();
|
||||
globalEvents.mostRecentGatheredNavigation = undefined;
|
||||
globalEvents.mostRecentRoutableExtensionRender = undefined;
|
||||
|
||||
// Set up a new tracker to test.
|
||||
trackerUnderTest = new Tracker(analyticsApiSpy);
|
||||
});
|
||||
|
||||
it('captures simple event with default context', () => {
|
||||
trackerUnderTest.captureEvent('click', 'test 1');
|
||||
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'click',
|
||||
subject: 'test 1',
|
||||
context: defaultContext,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('captures simple event with set context', () => {
|
||||
trackerUnderTest.setContext({
|
||||
pluginId: 'catalog',
|
||||
extensionId: 'App',
|
||||
});
|
||||
trackerUnderTest.captureEvent('click', 'test 2', {
|
||||
value: 2,
|
||||
attributes: { to: '/page-2' },
|
||||
});
|
||||
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'click',
|
||||
subject: 'test 2',
|
||||
value: 2,
|
||||
attributes: { to: '/page-2' },
|
||||
context: {
|
||||
pluginId: 'catalog',
|
||||
extensionId: 'App',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('accurate navigate events', () => {
|
||||
it('never captures _routeNodeType context key on navigate event', () => {
|
||||
trackerUnderTest.setContext({
|
||||
pluginId: 'catalog',
|
||||
extensionId: 'App',
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-3');
|
||||
|
||||
const receivedContext =
|
||||
analyticsApiSpy.captureEvent.mock.calls[0][0].context;
|
||||
expect(receivedContext.pluginId).toBe('catalog');
|
||||
expect(receivedContext._routeNodeType).toBe(undefined);
|
||||
});
|
||||
|
||||
it('never immediately captures navigate event with _routeNodeType "gathered"', () => {
|
||||
trackerUnderTest.setContext({
|
||||
...defaultContext,
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-4');
|
||||
|
||||
expect(analyticsApiSpy.captureEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never captures "routable-extension-rendered" events', () => {
|
||||
trackerUnderTest.captureEvent(routableExtensionRenderedEvent, '');
|
||||
|
||||
expect(analyticsApiSpy.captureEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('captures deferred navigate event with expected context', () => {
|
||||
// User navigates to a gathered mountpoint with multiple plugins
|
||||
trackerUnderTest.setContext({
|
||||
...defaultContext,
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-5');
|
||||
|
||||
// A routable extension is rendered with specific plugin context
|
||||
trackerUnderTest.setContext({
|
||||
pluginId: 'some-plugin',
|
||||
extensionId: 'App',
|
||||
});
|
||||
trackerUnderTest.captureEvent(routableExtensionRenderedEvent, '');
|
||||
|
||||
// A non-navigate event
|
||||
trackerUnderTest.captureEvent('click', 'test 5');
|
||||
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(2);
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: 'navigate',
|
||||
subject: '/page-5',
|
||||
context: {
|
||||
pluginId: 'some-plugin',
|
||||
extensionId: 'App',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
action: 'click',
|
||||
subject: 'test 5',
|
||||
context: {
|
||||
pluginId: 'some-plugin',
|
||||
extensionId: 'App',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('captures deferred navigate event with expected context when second event is also deferrable', () => {
|
||||
// User navigates to a gathered mountpoint with multiple plugins
|
||||
trackerUnderTest.setContext({
|
||||
...defaultContext,
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-6');
|
||||
|
||||
// A routable extension is rendered with specific plugin context
|
||||
trackerUnderTest.setContext({
|
||||
pluginId: 'another-plugin',
|
||||
extensionId: 'App',
|
||||
});
|
||||
trackerUnderTest.captureEvent(routableExtensionRenderedEvent, '');
|
||||
|
||||
// User navigates to another gathered mountpoint with multiple plugins
|
||||
trackerUnderTest.setContext({
|
||||
...defaultContext,
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-6-2');
|
||||
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: 'navigate',
|
||||
subject: '/page-6',
|
||||
context: {
|
||||
pluginId: 'another-plugin',
|
||||
extensionId: 'App',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('captures deferred navigate event with default context when no extension is rendered in between', () => {
|
||||
// User navigates to a gathered mountpoint with multiple plugins
|
||||
trackerUnderTest.setContext({
|
||||
...defaultContext,
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-7');
|
||||
|
||||
// A non-navigate event with no routable extension render in between
|
||||
trackerUnderTest.captureEvent('click', 'test 7');
|
||||
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(2);
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: 'navigate',
|
||||
subject: '/page-7',
|
||||
context: defaultContext,
|
||||
}),
|
||||
);
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
action: 'click',
|
||||
subject: 'test 7',
|
||||
context: defaultContext,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('captures deferred navigate event with expected context across separate trackers', () => {
|
||||
// User navigates to a gathered mountpoint with multiple plugins
|
||||
trackerUnderTest.setContext({
|
||||
...defaultContext,
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-8');
|
||||
|
||||
// A routable extension is rendered with specific plugin context and
|
||||
// captured via a separate tracker instance.
|
||||
const anotherTracker = new Tracker(analyticsApiSpy, {
|
||||
pluginId: 'the-plugin',
|
||||
extensionId: 'App',
|
||||
});
|
||||
anotherTracker.captureEvent(routableExtensionRenderedEvent, '');
|
||||
|
||||
// A non-navigate event is captured
|
||||
const aThirdTracker = new Tracker(analyticsApiSpy);
|
||||
aThirdTracker.captureEvent('click', 'test 8');
|
||||
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(2);
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: 'navigate',
|
||||
subject: '/page-8',
|
||||
context: {
|
||||
pluginId: 'the-plugin',
|
||||
extensionId: 'App',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
action: 'click',
|
||||
subject: 'test 8',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces extension context with "App" when capturing deferred events', () => {
|
||||
// User navigates to a gathered mountpoint with multiple plugins
|
||||
trackerUnderTest.setContext({
|
||||
...defaultContext,
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-9');
|
||||
|
||||
// A routable extension is rendered with specific plugin context
|
||||
trackerUnderTest.setContext({
|
||||
pluginId: 'very-plugin',
|
||||
extensionId: 'ShouldBeReplaced',
|
||||
});
|
||||
trackerUnderTest.captureEvent(routableExtensionRenderedEvent, '');
|
||||
|
||||
// A non-navigate event
|
||||
trackerUnderTest.captureEvent('click', 'test 9');
|
||||
|
||||
// Extension context should have been replaced with just "App"
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: 'navigate',
|
||||
subject: '/page-9',
|
||||
context: {
|
||||
pluginId: 'very-plugin',
|
||||
extensionId: 'App',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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 { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
|
||||
import {
|
||||
AnalyticsApi,
|
||||
AnalyticsEventAttributes,
|
||||
AnalyticsTracker,
|
||||
} from '../apis';
|
||||
import { AnalyticsContextValue } from './';
|
||||
|
||||
type TempGlobalEvents = {
|
||||
/**
|
||||
* Stores the most recent "gathered" mountpoint navigation.
|
||||
*/
|
||||
mostRecentGatheredNavigation?: {
|
||||
action: string;
|
||||
subject: string;
|
||||
value?: number;
|
||||
attributes?: AnalyticsEventAttributes;
|
||||
context: AnalyticsContextValue;
|
||||
};
|
||||
/**
|
||||
* Stores the most recent routable extension render.
|
||||
*/
|
||||
mostRecentRoutableExtensionRender?: {
|
||||
context: AnalyticsContextValue;
|
||||
};
|
||||
/**
|
||||
* Tracks whether or not a beforeunload event listener has already been
|
||||
* registered.
|
||||
*/
|
||||
beforeUnloadRegistered: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Temporary global store for select event data. Used to make `navigate` events
|
||||
* more accurate when gathered mountpoints are used.
|
||||
*/
|
||||
const globalEvents = getOrCreateGlobalSingleton<TempGlobalEvents>(
|
||||
'core-plugin-api:analytics-tracker-events',
|
||||
() => ({
|
||||
mostRecentGatheredNavigation: undefined,
|
||||
mostRecentRoutableExtensionRender: undefined,
|
||||
beforeUnloadRegistered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Internal-only event representing when a routable extension is rendered.
|
||||
*/
|
||||
export const routableExtensionRenderedEvent = '_ROUTABLE-EXTENSION-RENDERED';
|
||||
|
||||
export class Tracker implements AnalyticsTracker {
|
||||
constructor(
|
||||
private readonly analyticsApi: AnalyticsApi,
|
||||
private context: AnalyticsContextValue = {
|
||||
pluginId: 'root',
|
||||
extensionId: 'App',
|
||||
},
|
||||
) {
|
||||
// Only register a single beforeunload event across all trackers.
|
||||
if (!globalEvents.beforeUnloadRegistered) {
|
||||
// Before the page unloads, attempt to capture any deferred navigation
|
||||
// events that haven't yet been captured.
|
||||
addEventListener(
|
||||
'beforeunload',
|
||||
() => {
|
||||
if (globalEvents.mostRecentGatheredNavigation) {
|
||||
this.analyticsApi.captureEvent({
|
||||
...globalEvents.mostRecentGatheredNavigation,
|
||||
...globalEvents.mostRecentRoutableExtensionRender,
|
||||
});
|
||||
globalEvents.mostRecentGatheredNavigation = undefined;
|
||||
globalEvents.mostRecentRoutableExtensionRender = undefined;
|
||||
}
|
||||
},
|
||||
{ once: true, passive: true },
|
||||
);
|
||||
|
||||
// Prevent duplicate handlers from being registered.
|
||||
globalEvents.beforeUnloadRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
setContext(context: AnalyticsContextValue) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
captureEvent(
|
||||
action: string,
|
||||
subject: string,
|
||||
{
|
||||
value,
|
||||
attributes,
|
||||
}: { value?: number; attributes?: AnalyticsEventAttributes } = {},
|
||||
) {
|
||||
// Never pass internal "_routeNodeType" context value.
|
||||
const context = this.context;
|
||||
|
||||
// Never fire the special "_routable-extension-rendered" internal event.
|
||||
if (action === routableExtensionRenderedEvent) {
|
||||
// But keep track of it if we're delaying a `navigate` event for a
|
||||
// a gathered route node type.
|
||||
if (globalEvents.mostRecentGatheredNavigation) {
|
||||
globalEvents.mostRecentRoutableExtensionRender = {
|
||||
context: {
|
||||
...context,
|
||||
extensionId: 'App',
|
||||
},
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If we are about to fire a real event, and we have an un-fired gathered
|
||||
// mountpoint navigation on the global store, we need to fire the navigate
|
||||
// event first, so this real event happens accurately after the navigation.
|
||||
if (globalEvents.mostRecentGatheredNavigation) {
|
||||
try {
|
||||
this.analyticsApi.captureEvent({
|
||||
...globalEvents.mostRecentGatheredNavigation,
|
||||
...globalEvents.mostRecentRoutableExtensionRender,
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Error during analytics event capture. %o', e);
|
||||
}
|
||||
|
||||
// Clear the global stores.
|
||||
globalEvents.mostRecentGatheredNavigation = undefined;
|
||||
globalEvents.mostRecentRoutableExtensionRender = undefined;
|
||||
}
|
||||
|
||||
// Never directly fire a navigation event on a gathered route with default
|
||||
// contextual details.
|
||||
if (action === 'navigate' && context.pluginId === 'root') {
|
||||
// Instead, set it on the global store.
|
||||
globalEvents.mostRecentGatheredNavigation = {
|
||||
action,
|
||||
subject,
|
||||
value,
|
||||
attributes,
|
||||
context,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.analyticsApi.captureEvent({
|
||||
action,
|
||||
subject,
|
||||
value,
|
||||
attributes,
|
||||
context,
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Error during analytics event capture. %o', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { AnalyticsContext } from './AnalyticsContext';
|
||||
export type { AnalyticsContextValue, CommonAnalyticsContext } from './types';
|
||||
export { useAnalytics } from './useAnalytics';
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Common analytics context attributes.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CommonAnalyticsContext = {
|
||||
/**
|
||||
* The nearest known parent plugin where the event was captured.
|
||||
*/
|
||||
pluginId: string;
|
||||
|
||||
/**
|
||||
* The nearest known parent extension where the event was captured.
|
||||
*/
|
||||
/**
|
||||
* The nearest known parent extension where the event was captured.
|
||||
*/
|
||||
extensionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analytics context envelope.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AnalyticsContextValue = CommonAnalyticsContext & {
|
||||
[param in string]: string | boolean | number | undefined;
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 { renderHook } from '@testing-library/react';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
import { analyticsApiRef } from '@backstage/core-plugin-api';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
|
||||
describe('useAnalytics', () => {
|
||||
it('returns tracker with no implementation defined', () => {
|
||||
// useApi throws an error because the Analytics API is not implemented
|
||||
// But the result should still have a captureEvent method.
|
||||
const { result } = renderHook(() => useAnalytics(), {});
|
||||
expect(result.current.captureEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns tracker from defined analytics api', () => {
|
||||
const captureEvent = jest.fn();
|
||||
|
||||
// Calling the captureEvent method of the underlying implementation should
|
||||
// pass along the given event as well as the default context.
|
||||
const { result } = renderHook(() => useAnalytics(), {
|
||||
wrapper: ({ children }) => (
|
||||
// Simulate useApi returning a valid tracker.
|
||||
<TestApiProvider apis={[[analyticsApiRef, { captureEvent }]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
result.current.captureEvent('an action', 'a subject', {
|
||||
value: 42,
|
||||
attributes: { some: 'value' },
|
||||
});
|
||||
expect(captureEvent).toHaveBeenCalledWith({
|
||||
action: 'an action',
|
||||
subject: 'a subject',
|
||||
value: 42,
|
||||
attributes: {
|
||||
some: 'value',
|
||||
},
|
||||
context: {
|
||||
extensionId: 'App',
|
||||
pluginId: 'root',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import { useAnalyticsContext } from './AnalyticsContext';
|
||||
import { analyticsApiRef, AnalyticsTracker, AnalyticsApi } from '../apis';
|
||||
import { useRef } from 'react';
|
||||
import { Tracker } from './Tracker';
|
||||
|
||||
function useAnalyticsApi(): AnalyticsApi {
|
||||
try {
|
||||
return useApi(analyticsApiRef);
|
||||
} catch {
|
||||
return { captureEvent: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a pre-configured analytics tracker.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useAnalytics(): AnalyticsTracker {
|
||||
const trackerRef = useRef<Tracker | null>(null);
|
||||
const context = useAnalyticsContext();
|
||||
// Our goal is to make this API truly optional for any/all consuming code
|
||||
// (including tests). This hook runs last to ensure hook order is, as much as
|
||||
// possible, maintained.
|
||||
const analyticsApi = useAnalyticsApi();
|
||||
|
||||
function getTracker(): Tracker {
|
||||
if (trackerRef.current === null) {
|
||||
trackerRef.current = new Tracker(analyticsApi);
|
||||
}
|
||||
return trackerRef.current;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 { ApiRef, createApiRef } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsContextValue } from '../../analytics/types';
|
||||
|
||||
/**
|
||||
* Represents an event worth tracking in an analytics system that could inform
|
||||
* how users of a Backstage instance are using its features.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AnalyticsEvent = {
|
||||
/**
|
||||
* A string that identifies the event being tracked by the type of action the
|
||||
* event represents. Be careful not to encode extra metadata in this string
|
||||
* that should instead be placed in the Analytics Context or attributes.
|
||||
* Examples include:
|
||||
*
|
||||
* - view
|
||||
* - click
|
||||
* - filter
|
||||
* - search
|
||||
* - hover
|
||||
* - scroll
|
||||
*/
|
||||
action: string;
|
||||
|
||||
/**
|
||||
* A string that uniquely identifies the object that the action is being
|
||||
* taken on. Examples include:
|
||||
*
|
||||
* - The path of the page viewed
|
||||
* - The url of the link clicked
|
||||
* - The value that was filtered by
|
||||
* - The text that was searched for
|
||||
*/
|
||||
subject: string;
|
||||
|
||||
/**
|
||||
* An optional numeric value relevant to the event that could be aggregated
|
||||
* by analytics tools. Examples include:
|
||||
*
|
||||
* - The index or position of the clicked element in an ordered list
|
||||
* - The percentage of an element that has been scrolled through
|
||||
* - The amount of time that has elapsed since a fixed point
|
||||
* - A satisfaction score on a fixed scale
|
||||
*/
|
||||
value?: number;
|
||||
|
||||
/**
|
||||
* Optional, additional attributes (representing dimensions or metrics)
|
||||
* specific to the event that could be forwarded on to analytics systems.
|
||||
*/
|
||||
attributes?: AnalyticsEventAttributes;
|
||||
|
||||
/**
|
||||
* Contextual metadata relating to where the event was captured and by whom.
|
||||
* This could include information about the route, plugin, or extension in
|
||||
* which an event was captured.
|
||||
*/
|
||||
context: AnalyticsContextValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* A structure allowing other arbitrary metadata to be provided by analytics
|
||||
* event emitters.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AnalyticsEventAttributes = {
|
||||
[attribute in string]: string | boolean | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a tracker with methods that can be called to track events in a
|
||||
* configured analytics service.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AnalyticsTracker = {
|
||||
captureEvent: (
|
||||
action: string,
|
||||
subject: string,
|
||||
options?: {
|
||||
value?: number;
|
||||
attributes?: AnalyticsEventAttributes;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Analytics API is used to track user behavior in a Backstage instance.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* To instrument your App or Plugin, retrieve an analytics tracker using the
|
||||
* useAnalytics() hook. This will return a pre-configured AnalyticsTracker
|
||||
* with relevant methods for instrumentation.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AnalyticsApi = {
|
||||
/**
|
||||
* Primary event handler responsible for compiling and forwarding events to
|
||||
* an analytics system.
|
||||
*/
|
||||
captureEvent(event: AnalyticsEvent): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The API reference of {@link AnalyticsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const analyticsApiRef: ApiRef<AnalyticsApi> = createApiRef({
|
||||
id: 'core.analytics',
|
||||
});
|
||||
@@ -42,3 +42,4 @@ export * from './FetchApi';
|
||||
export * from './IdentityApi';
|
||||
export * from './OAuthRequestApi';
|
||||
export * from './StorageApi';
|
||||
export * from './AnalyticsApi';
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './analytics';
|
||||
export * from './apis';
|
||||
export * from './components';
|
||||
export * from './extensions';
|
||||
|
||||
Reference in New Issue
Block a user