diff --git a/.changeset/analytics-gather-defer.md b/.changeset/analytics-gather-defer.md new file mode 100644 index 0000000000..7b30f0b655 --- /dev/null +++ b/.changeset/analytics-gather-defer.md @@ -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 ``, ``, and similar components that are used to gather one or more routable extensions under a given path. diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index fa9aed7fe5..c218a99d18 100644 --- a/docs/plugins/analytics.md +++ b/docs/plugins/analytics.md @@ -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 diff --git a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts index f99a7b71f3..7cd9eae021 100644 --- a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts @@ -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(), +}; const ref1 = createRouteRef({ id: 'rr1' }); const ref2 = createRouteRef({ id: 'rr2', params: ['x'] }); diff --git a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts index ecc854f474..41e5aba712 100644 --- a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts @@ -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(), +}; const ref1 = createRouteRef({ id: 'rr1' }); const ref2 = createRouteRef({ id: 'rr2', params: ['x'] }); diff --git a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts index 9cf1fb2fb1..8c484effae 100644 --- a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts @@ -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(), +}; const ref1 = createRouteRef({ id: 'rr1' }); const ref2 = createRouteRef({ id: 'rr2', params: ['x'] }); diff --git a/packages/core-app-api/src/routing/RouteTracker.test.tsx b/packages/core-app-api/src/routing/RouteTracker.test.tsx index 6896b60169..6bf58a117e 100644 --- a/packages/core-app-api/src/routing/RouteTracker.test.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.test.tsx @@ -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: go, routeRefs: new Set([routeRef1]), + plugins: new Set([plugin1]), caseSensitive: false, }, { path: '/path2/:param', element:
hi there
, 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', diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index 5ec858b28e..f1910728e3 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -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((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 { diff --git a/packages/core-app-api/src/routing/collectors.beta.test.tsx b/packages/core-app-api/src/routing/collectors.beta.test.tsx index 74d8bbbe53..057674a7e4 100644 --- a/packages/core-app-api/src/routing/collectors.beta.test.tsx +++ b/packages/core-app-api/src/routing/collectors.beta.test.tsx @@ -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, }; } diff --git a/packages/core-app-api/src/routing/collectors.compat.test.tsx b/packages/core-app-api/src/routing/collectors.compat.test.tsx index e1333a070a..5dab06d03e 100644 --- a/packages/core-app-api/src/routing/collectors.compat.test.tsx +++ b/packages/core-app-api/src/routing/collectors.compat.test.tsx @@ -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, ], diff --git a/packages/core-app-api/src/routing/collectors.stable.test.tsx b/packages/core-app-api/src/routing/collectors.stable.test.tsx index de6f350cc9..20c7643b9e 100644 --- a/packages/core-app-api/src/routing/collectors.stable.test.tsx +++ b/packages/core-app-api/src/routing/collectors.stable.test.tsx @@ -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, diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx index 0bf5e676a4..23f05fe62a 100644 --- a/packages/core-app-api/src/routing/collectors.tsx +++ b/packages/core-app-api/src/routing/collectors.tsx @@ -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 => { + const set = new Set(); + if (plugin) { + set.add(plugin); + } + return set; +}; + interface RoutingV2CollectorContext { routeRef?: RouteRef; gatherPath?: string; @@ -132,7 +143,7 @@ export const routingV2Collector = createCollector( routeRefs: new Set(), caseSensitive: Boolean(node.props?.caseSensitive), children: [MATCH_ALL_ROUTE], - plugin: undefined, + plugins: new Set(), }; 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( + 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( - node.props.element, - 'core.plugin', + plugins: pluginSet( + getComponentData( + 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); } diff --git a/packages/core-app-api/src/routing/types.ts b/packages/core-app-api/src/routing/types.ts index e2cf665982..56b82e4b53 100644 --- a/packages/core-app-api/src/routing/types.ts +++ b/packages/core-app-api/src/routing/types.ts @@ -54,7 +54,7 @@ export interface BackstageRouteObject { element: React.ReactNode; path: string; routeRefs: Set; - plugin?: BackstagePlugin; + plugins: Set; } export function isRouteRef( diff --git a/packages/core-plugin-api/src/analytics/Tracker.test.ts b/packages/core-plugin-api/src/analytics/Tracker.test.ts new file mode 100644 index 0000000000..e0e3a87f6d --- /dev/null +++ b/packages/core-plugin-api/src/analytics/Tracker.test.ts @@ -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( + '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', + }, + }), + ); + }); + }); +}); diff --git a/packages/core-plugin-api/src/analytics/Tracker.ts b/packages/core-plugin-api/src/analytics/Tracker.ts index 51e99bff14..130410e2a2 100644 --- a/packages/core-plugin-api/src/analytics/Tracker.ts +++ b/packages/core-plugin-api/src/analytics/Tracker.ts @@ -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( + '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 diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index e65280981f..a9a3127aee 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -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 ; };