Merge pull request #17999 from backstage/analytics/handle-gathered-mountpoints

[Analytics] Handle gathered mountpoints
This commit is contained in:
Patrik Oldsberg
2023-06-20 15:39:18 +02:00
committed by GitHub
15 changed files with 530 additions and 29 deletions
+6
View File
@@ -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.
+7 -7
View File
@@ -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);
}
+1 -1
View File
@@ -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} />;
};