Merge pull request #17999 from backstage/analytics/handle-gathered-mountpoints
[Analytics] Handle gathered mountpoints
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
'@backstage/core-plugin-api': patch
|
||||
---
|
||||
|
||||
Fixed a bug that prevented accurate plugin and route data from being applied to `navigate` analytics events when users visited pages constructed with `<EntityLayout>`, `<TabbedLayout>`, and similar components that are used to gather one or more routable extensions under a given path.
|
||||
@@ -52,13 +52,13 @@ learn how to contribute the integration yourself!
|
||||
The following table summarizes events that, depending on the plugins you have
|
||||
installed, may be captured.
|
||||
|
||||
| Action | Subject | Other Notes |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| `discover` | The title of the search result that was clicked on | The `value` is the result rank. A `to` attribute is also provided. |
|
||||
| Action | Subject | Other Notes |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `navigate` | The URL of the page that was navigated to. | Fired immediately when route location changes (unless associated plugin/route data is ambiguous, in which case the event is fired after plugin/route data becomes known, immediately before the next event or document unload). 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. |
|
||||
| `discover` | The title of the search result that was clicked on | The `value` is the result rank. A `to` attribute is also provided. |
|
||||
|
||||
If there is an event you'd like to see captured, please [open an
|
||||
issue](https://github.com/backstage/backstage/issues/new?assignees=&labels=enhancement&template=feature_template.md&title=[Analytics%20Event]:%20THE+EVENT+TO+CAPTURE) describing the event you want to see and the questions it
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ExternalRouteRef,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
BackstagePlugin,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { RouteResolver } from './RouteResolver';
|
||||
import { MATCH_ALL_ROUTE } from './collectors';
|
||||
@@ -31,7 +32,12 @@ jest.mock('react-router-dom', () =>
|
||||
);
|
||||
|
||||
const element = () => null;
|
||||
const rest = { element, caseSensitive: false, children: [MATCH_ALL_ROUTE] };
|
||||
const rest = {
|
||||
element,
|
||||
caseSensitive: false,
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugins: new Set<BackstagePlugin>(),
|
||||
};
|
||||
|
||||
const ref1 = createRouteRef({ id: 'rr1' });
|
||||
const ref2 = createRouteRef({ id: 'rr2', params: ['x'] });
|
||||
|
||||
@@ -21,11 +21,17 @@ import {
|
||||
ExternalRouteRef,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
BackstagePlugin,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { MATCH_ALL_ROUTE } from './collectors';
|
||||
|
||||
const element = () => null;
|
||||
const rest = { element, caseSensitive: false, children: [MATCH_ALL_ROUTE] };
|
||||
const rest = {
|
||||
element,
|
||||
caseSensitive: false,
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugins: new Set<BackstagePlugin>(),
|
||||
};
|
||||
|
||||
const ref1 = createRouteRef({ id: 'rr1' });
|
||||
const ref2 = createRouteRef({ id: 'rr2', params: ['x'] });
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ExternalRouteRef,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
BackstagePlugin,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { RouteResolver } from './RouteResolver';
|
||||
import { MATCH_ALL_ROUTE } from './collectors';
|
||||
@@ -31,7 +32,12 @@ jest.mock('react-router-dom', () =>
|
||||
);
|
||||
|
||||
const element = () => null;
|
||||
const rest = { element, caseSensitive: false, children: [MATCH_ALL_ROUTE] };
|
||||
const rest = {
|
||||
element,
|
||||
caseSensitive: false,
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugins: new Set<BackstagePlugin>(),
|
||||
};
|
||||
|
||||
const ref1 = createRouteRef({ id: 'rr1' });
|
||||
const ref2 = createRouteRef({ id: 'rr2', params: ['x'] });
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import {
|
||||
AnalyticsApi,
|
||||
analyticsApiRef,
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
@@ -32,18 +33,21 @@ describe('RouteTracker', () => {
|
||||
const routeRef2 = createRouteRef({
|
||||
id: 'route2',
|
||||
});
|
||||
const plugin1 = createPlugin({ id: 'plugin1' });
|
||||
|
||||
const routeObjects: BackstageRouteObject[] = [
|
||||
{
|
||||
path: '/path/:p1/:p2',
|
||||
element: <Link to="/path2/hello">go</Link>,
|
||||
routeRefs: new Set([routeRef1]),
|
||||
plugins: new Set([plugin1]),
|
||||
caseSensitive: false,
|
||||
},
|
||||
{
|
||||
path: '/path2/:param',
|
||||
element: <div>hi there</div>,
|
||||
routeRefs: new Set([routeRef2]),
|
||||
plugins: new Set(),
|
||||
caseSensitive: false,
|
||||
},
|
||||
];
|
||||
@@ -73,7 +77,7 @@ describe('RouteTracker', () => {
|
||||
},
|
||||
context: {
|
||||
extension: 'App',
|
||||
pluginId: 'root',
|
||||
pluginId: 'plugin1',
|
||||
routeRef: 'route1',
|
||||
},
|
||||
subject: '/path/foo/bar',
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AnalyticsContext,
|
||||
RouteRef,
|
||||
AnalyticsEventAttributes,
|
||||
BackstagePlugin,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { BackstageRouteObject } from './types';
|
||||
|
||||
@@ -50,13 +51,18 @@ const getExtensionContext = (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If there is a single route ref, return it.
|
||||
// todo: get routeRef of rendered gathered mount point(?)
|
||||
// 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]) => {
|
||||
@@ -68,8 +74,9 @@ const getExtensionContext = (
|
||||
|
||||
return {
|
||||
extension: 'App',
|
||||
pluginId: routeObject.plugin?.getId() || 'root',
|
||||
pluginId: plugin?.getId() || 'root',
|
||||
...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}),
|
||||
_routeNodeType: routeObject.element as string,
|
||||
params,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -112,16 +112,17 @@ function routeObj(
|
||||
caseSensitive: false,
|
||||
element: type,
|
||||
routeRefs: new Set(refs),
|
||||
plugins: backstagePlugin ? new Set([backstagePlugin]) : new Set(),
|
||||
children: [
|
||||
{
|
||||
path: '*',
|
||||
caseSensitive: false,
|
||||
element: 'match-all',
|
||||
routeRefs: new Set(),
|
||||
plugins: new Set(),
|
||||
},
|
||||
...children,
|
||||
],
|
||||
plugin: backstagePlugin,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -129,12 +129,14 @@ describe.each(['beta', 'stable'])('react-router %s', rrVersion => {
|
||||
caseSensitive: false,
|
||||
element: type,
|
||||
routeRefs: new Set(refs),
|
||||
plugins: new Set(),
|
||||
children: [
|
||||
{
|
||||
path: '*',
|
||||
caseSensitive: false,
|
||||
element: 'match-all',
|
||||
routeRefs: new Set(),
|
||||
plugins: new Set(),
|
||||
},
|
||||
...children,
|
||||
],
|
||||
|
||||
@@ -118,10 +118,11 @@ function routeObj(
|
||||
caseSensitive: false,
|
||||
element: 'match-all',
|
||||
routeRefs: new Set(),
|
||||
plugins: new Set(),
|
||||
},
|
||||
...children,
|
||||
],
|
||||
plugin: backstagePlugin,
|
||||
plugins: backstagePlugin ? new Set([backstagePlugin]) : new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,11 +334,11 @@ describe('discovery', () => {
|
||||
[ref5, ref3],
|
||||
]);
|
||||
expect(routing.objects).toEqual([
|
||||
routeObj('foo', [ref1, ref2], [], 'gathered'),
|
||||
routeObj('foo', [ref1, ref2], [], 'gathered', plugin),
|
||||
routeObj(
|
||||
'bar',
|
||||
[ref3],
|
||||
[routeObj('', [ref4, ref5], [], 'gathered')],
|
||||
[routeObj('', [ref4, ref5], [], 'gathered', plugin)],
|
||||
undefined,
|
||||
plugin,
|
||||
),
|
||||
@@ -403,6 +404,7 @@ describe('discovery', () => {
|
||||
),
|
||||
],
|
||||
'gathered',
|
||||
plugin,
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
|
||||
@@ -33,6 +33,7 @@ export const MATCH_ALL_ROUTE: BackstageRouteObject = {
|
||||
path: '*',
|
||||
element: 'match-all', // These elements aren't used, so we add in a bit of debug information
|
||||
routeRefs: new Set(),
|
||||
plugins: new Set(),
|
||||
};
|
||||
|
||||
function stringifyNode(node: ReactNode): string {
|
||||
@@ -45,6 +46,16 @@ function stringifyNode(node: ReactNode): string {
|
||||
return String(anyNode);
|
||||
}
|
||||
|
||||
const pluginSet = (
|
||||
plugin: BackstagePlugin | undefined,
|
||||
): Set<BackstagePlugin> => {
|
||||
const set = new Set<BackstagePlugin>();
|
||||
if (plugin) {
|
||||
set.add(plugin);
|
||||
}
|
||||
return set;
|
||||
};
|
||||
|
||||
interface RoutingV2CollectorContext {
|
||||
routeRef?: RouteRef;
|
||||
gatherPath?: string;
|
||||
@@ -132,7 +143,7 @@ export const routingV2Collector = createCollector(
|
||||
routeRefs: new Set<RouteRef>(),
|
||||
caseSensitive: Boolean(node.props?.caseSensitive),
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugin: undefined,
|
||||
plugins: new Set<BackstagePlugin>(),
|
||||
};
|
||||
parentChildren.push(newObj);
|
||||
|
||||
@@ -162,7 +173,7 @@ export const routingV2Collector = createCollector(
|
||||
routeRefs: new Set([routeRef]),
|
||||
caseSensitive: Boolean(node.props?.caseSensitive),
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugin,
|
||||
plugins: pluginSet(plugin),
|
||||
};
|
||||
parentChildren.push(newObj);
|
||||
acc.paths.set(routeRef, path);
|
||||
@@ -187,6 +198,15 @@ export const routingV2Collector = createCollector(
|
||||
}
|
||||
|
||||
ctx?.obj?.routeRefs.add(mountPoint);
|
||||
|
||||
const mountPointPlugin = getComponentData<BackstagePlugin>(
|
||||
node,
|
||||
'core.plugin',
|
||||
);
|
||||
if (mountPointPlugin) {
|
||||
ctx?.obj?.plugins.add(mountPointPlugin);
|
||||
}
|
||||
|
||||
acc.paths.set(mountPoint, ctx.gatherPath);
|
||||
acc.parents.set(mountPoint, ctx?.gatherRouteRef);
|
||||
|
||||
@@ -306,9 +326,11 @@ export const routingV1Collector = createCollector(
|
||||
element: 'mounted',
|
||||
routeRefs: new Set([routeRef]),
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugin: getComponentData<BackstagePlugin>(
|
||||
node.props.element,
|
||||
'core.plugin',
|
||||
plugins: pluginSet(
|
||||
getComponentData<BackstagePlugin>(
|
||||
node.props.element,
|
||||
'core.plugin',
|
||||
),
|
||||
),
|
||||
};
|
||||
parentChildren.push(currentObj);
|
||||
@@ -336,7 +358,7 @@ export const routingV1Collector = createCollector(
|
||||
element: 'gathered',
|
||||
routeRefs: new Set(),
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugin: ctx?.obj?.plugin,
|
||||
plugins: ctx?.obj?.plugins || new Set(),
|
||||
};
|
||||
parentChildren.push(currentObj);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface BackstageRouteObject {
|
||||
element: React.ReactNode;
|
||||
path: string;
|
||||
routeRefs: Set<RouteRef>;
|
||||
plugin?: BackstagePlugin;
|
||||
plugins: Set<BackstagePlugin>;
|
||||
}
|
||||
|
||||
export function isRouteRef<Params extends AnyParams>(
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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 = {
|
||||
routeRef: 'unknown',
|
||||
pluginId: 'root',
|
||||
extension: '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({
|
||||
routeRef: 'catalog:entity',
|
||||
pluginId: 'catalog',
|
||||
extension: '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: {
|
||||
routeRef: 'catalog:entity',
|
||||
pluginId: 'catalog',
|
||||
extension: 'App',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('accurate navigate events', () => {
|
||||
it('never captures _routeNodeType context key on navigate event', () => {
|
||||
trackerUnderTest.setContext({
|
||||
routeRef: 'catalog:entity',
|
||||
pluginId: 'catalog',
|
||||
extension: 'App',
|
||||
_routeNodeType: 'mounted',
|
||||
});
|
||||
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,
|
||||
_routeNodeType: 'gathered',
|
||||
});
|
||||
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,
|
||||
_routeNodeType: 'gathered',
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-5');
|
||||
|
||||
// A routable extension is rendered with specific plugin context
|
||||
trackerUnderTest.setContext({
|
||||
routeRef: 'some:ref',
|
||||
pluginId: 'some-plugin',
|
||||
extension: '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: {
|
||||
routeRef: 'some:ref',
|
||||
pluginId: 'some-plugin',
|
||||
extension: 'App',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
action: 'click',
|
||||
subject: 'test 5',
|
||||
context: {
|
||||
routeRef: 'some:ref',
|
||||
pluginId: 'some-plugin',
|
||||
extension: '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,
|
||||
_routeNodeType: 'gathered',
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-6');
|
||||
|
||||
// A routable extension is rendered with specific plugin context
|
||||
trackerUnderTest.setContext({
|
||||
routeRef: 'another:ref',
|
||||
pluginId: 'another-plugin',
|
||||
extension: 'App',
|
||||
});
|
||||
trackerUnderTest.captureEvent(routableExtensionRenderedEvent, '');
|
||||
|
||||
// User navigates to another gathered mountpoint with multiple plugins
|
||||
trackerUnderTest.setContext({
|
||||
...defaultContext,
|
||||
_routeNodeType: 'gathered',
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-6-2');
|
||||
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: 'navigate',
|
||||
subject: '/page-6',
|
||||
context: {
|
||||
routeRef: 'another:ref',
|
||||
pluginId: 'another-plugin',
|
||||
extension: '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,
|
||||
_routeNodeType: 'gathered',
|
||||
});
|
||||
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,
|
||||
_routeNodeType: 'gathered',
|
||||
});
|
||||
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, {
|
||||
routeRef: 'the:ref',
|
||||
pluginId: 'the-plugin',
|
||||
extension: '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: {
|
||||
routeRef: 'the:ref',
|
||||
pluginId: 'the-plugin',
|
||||
extension: '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,
|
||||
_routeNodeType: 'gathered',
|
||||
});
|
||||
trackerUnderTest.captureEvent('navigate', '/page-9');
|
||||
|
||||
// A routable extension is rendered with specific plugin context
|
||||
trackerUnderTest.setContext({
|
||||
routeRef: 'very:ref',
|
||||
pluginId: 'very-plugin',
|
||||
extension: '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: {
|
||||
routeRef: 'very:ref',
|
||||
pluginId: 'very-plugin',
|
||||
extension: 'App',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
|
||||
import {
|
||||
AnalyticsApi,
|
||||
AnalyticsEventAttributes,
|
||||
@@ -21,6 +22,48 @@ import {
|
||||
} 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,
|
||||
@@ -29,7 +72,30 @@ export class Tracker implements AnalyticsTracker {
|
||||
pluginId: 'root',
|
||||
extension: '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;
|
||||
@@ -43,13 +109,68 @@ export class Tracker implements AnalyticsTracker {
|
||||
attributes,
|
||||
}: { value?: number; attributes?: AnalyticsEventAttributes } = {},
|
||||
) {
|
||||
// Never pass internal "_routeNodeType" context value.
|
||||
const { _routeNodeType, ...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,
|
||||
extension: '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' &&
|
||||
_routeNodeType === 'gathered' &&
|
||||
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: this.context,
|
||||
context,
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@@ -14,14 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { AnalyticsContext } from '../analytics';
|
||||
import React, { lazy, Suspense, useEffect } from 'react';
|
||||
import { AnalyticsContext, useAnalytics } from '../analytics';
|
||||
import { useApp } from '../app';
|
||||
import { RouteRef, useRouteRef } from '../routing';
|
||||
import { attachComponentData } from './componentData';
|
||||
import { Extension, BackstagePlugin } from '../plugin';
|
||||
import { PluginErrorBoundary } from './PluginErrorBoundary';
|
||||
import { PluginProvider } from '../plugin-options';
|
||||
import { routableExtensionRenderedEvent } from '../analytics/Tracker';
|
||||
|
||||
/**
|
||||
* Lazy or synchronous retrieving of extension components.
|
||||
@@ -82,6 +83,8 @@ export function createRoutableExtension<
|
||||
component().then(
|
||||
InnerComponent => {
|
||||
const RoutableExtensionWrapper: any = (props: any) => {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
// Validate that the routing is wired up correctly in the App.tsx
|
||||
try {
|
||||
useRouteRef(mountPoint);
|
||||
@@ -101,6 +104,15 @@ export function createRoutableExtension<
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// This event, never exposed to end-users of the analytics API,
|
||||
// helps inform which extension metadata gets associated with a
|
||||
// navigation event when the route navigated to is a gathered
|
||||
// mountpoint.
|
||||
useEffect(() => {
|
||||
analytics.captureEvent(routableExtensionRenderedEvent, '');
|
||||
}, [analytics]);
|
||||
|
||||
return <InnerComponent {...props} />;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user