diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index 39f4c6f269..f1910728e3 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -51,12 +51,15 @@ 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; - let plugin: BackstagePlugin | 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; } @@ -73,6 +76,7 @@ const getExtensionContext = ( extension: 'App', pluginId: plugin?.getId() || 'root', ...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}), + _routeNodeType: routeObject.element as string, params, }; } catch { diff --git a/packages/core-plugin-api/src/analytics/Tracker.ts b/packages/core-plugin-api/src/analytics/Tracker.ts index 51e99bff14..3a46b4e4d0 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,42 @@ 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; + }; +}; + +/** + * 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, + }), +); + +/** + * 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, @@ -43,13 +80,65 @@ 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) { + // Instead, set it on the global store. + 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 ; };