Handle navigate events with multiple gathered extensions

Signed-off-by: Eric Peterson <i.am@eric.pe>
This commit is contained in:
Eric Peterson
2023-05-28 20:04:45 +02:00
parent 1ff191d593
commit f4a693ea05
3 changed files with 111 additions and 6 deletions
@@ -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 {
@@ -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<TempGlobalEvents>(
'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
@@ -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} />;
};