;
+ };
+
+ render(
+
+
+
+
+ } />
+
+
+ ,
+ );
+
+ expect(mockedAnalytics.captureEvent).toHaveBeenNthCalledWith(1, {
+ action: 'navigate',
+ attributes: {},
+ context: {
+ extensionId: 'App',
+ pluginId: 'root',
+ },
+ subject: '/not-routable-extension',
+ value: undefined,
+ });
+ expect(mockedAnalytics.captureEvent).toHaveBeenNthCalledWith(2, {
+ action: 'click',
+ attributes: undefined,
+ context: {
+ extensionId: 'App',
+ pluginId: 'root',
+ },
+ subject: 'test',
+ value: undefined,
+ });
+ });
+
+ it('should return parent route context on navigating to a sub-route', async () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
+ action: 'navigate',
+ attributes: {
+ param: 'param-value',
+ },
+ context: {
+ extensionId: 'plugin2.page.index',
+ pluginId: 'plugin2',
+ },
+ subject: '/path2/param-value/sub-route',
+ value: undefined,
+ });
+ });
+});
diff --git a/packages/frontend-app-api/src/routing/RouteTracker.tsx b/packages/frontend-app-api/src/routing/RouteTracker.tsx
new file mode 100644
index 0000000000..8cddca5c68
--- /dev/null
+++ b/packages/frontend-app-api/src/routing/RouteTracker.tsx
@@ -0,0 +1,129 @@
+/*
+ * 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 React, { useEffect } from 'react';
+import { matchRoutes, useLocation } from 'react-router-dom';
+import {
+ useAnalytics,
+ AnalyticsContext,
+ AnalyticsEventAttributes,
+} from '@backstage/frontend-plugin-api';
+import { BackstageRouteObject } from './types';
+
+/**
+ * Returns an extension context given the current pathname and a list of
+ * Backstage route objects.
+ */
+const getExtensionContext = (
+ pathname: string,
+ routes: BackstageRouteObject[],
+) => {
+ try {
+ // Find matching routes for the given path name.
+ const matches = matchRoutes(routes, { pathname });
+
+ // Of the matching routes, get the last (e.g. most specific) instance of
+ // the BackstageRouteObject that contains a routeRef. Filtering by routeRef
+ // ensures subRouteRefs are aligned to their parent routes' context.
+ const routeMatch = matches
+ ?.filter(match => match?.route.routeRefs?.size > 0)
+ .pop();
+ const routeObject = routeMatch?.route;
+
+ // If there is no route object, then allow inheritance of default context.
+ if (!routeObject) {
+ return undefined;
+ }
+
+ // If the matched route is the root route (no path), and the pathname is
+ // not the path of the homepage, then inherit from the default context.
+ if (routeObject.path === '' && pathname !== '/') {
+ return undefined;
+ }
+
+ const params = Object.entries(
+ routeMatch?.params || {},
+ ).reduce((acc, [key, value]) => {
+ if (value !== undefined && key !== '*') {
+ acc[key] = value;
+ }
+ return acc;
+ }, {});
+
+ const plugin = routeObject.appNode?.spec.source;
+ const extension = routeObject.appNode?.spec.extension;
+
+ return {
+ params,
+ pluginId: plugin?.id || 'root',
+ extensionId: extension?.id || 'App',
+ };
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Performs the actual event capture on render.
+ */
+const TrackNavigation = ({
+ pathname,
+ search,
+ hash,
+ attributes,
+}: {
+ pathname: string;
+ search: string;
+ hash: string;
+ attributes?: AnalyticsEventAttributes;
+}) => {
+ const analytics = useAnalytics();
+ useEffect(() => {
+ analytics.captureEvent('navigate', `${pathname}${search}${hash}`, {
+ attributes,
+ });
+ }, [analytics, pathname, search, hash, attributes]);
+
+ return null;
+};
+
+/**
+ * Logs a "navigate" event with appropriate plugin-level analytics context
+ * attributes each time the user navigates to a page.
+ */
+export const RouteTracker = ({
+ routeObjects,
+}: {
+ routeObjects: BackstageRouteObject[];
+}) => {
+ const { pathname, search, hash } = useLocation();
+
+ const { params, ...attributes } = getExtensionContext(
+ pathname,
+ routeObjects,
+ ) || { params: {} };
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
index 112e1435ae..c94a06a442 100644
--- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
+++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
@@ -19,6 +19,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api';
import { extractRouteInfoFromAppNode } from './extractRouteInfoFromAppNode';
import {
AnyRouteRefParams,
+ AppNode,
Extension,
RouteRef,
coreExtensionData,
@@ -99,8 +100,10 @@ function routeObj(
children: any[] = [],
type: 'mounted' | 'gathered' = 'mounted',
backstagePlugin?: BackstagePlugin,
+ appNode?: AppNode,
) {
return {
+ appNode,
path: path,
caseSensitive: false,
element: type,
@@ -171,7 +174,14 @@ describe('discovery', () => {
[ref5, ref1],
]);
expect(info.routeObjects).toEqual([
- routeObj('nothing', []),
+ routeObj(
+ 'nothing',
+ [],
+ undefined,
+ undefined,
+ undefined,
+ expect.any(Object),
+ ),
routeObj(
'foo',
[ref1],
@@ -179,16 +189,41 @@ describe('discovery', () => {
routeObj(
'bar/:id',
[ref2],
- [routeObj('baz', [ref3], undefined, undefined, expect.any(Object))],
+ [
+ routeObj(
+ 'baz',
+ [ref3],
+ undefined,
+ undefined,
+ expect.any(Object),
+ expect.any(Object),
+ ),
+ ],
undefined,
expect.any(Object),
+ expect.any(Object),
+ ),
+ routeObj(
+ 'blop',
+ [ref5],
+ undefined,
+ undefined,
+ expect.any(Object),
+ expect.any(Object),
),
- routeObj('blop', [ref5], undefined, undefined, expect.any(Object)),
],
undefined,
expect.any(Object),
+ expect.any(Object),
+ ),
+ routeObj(
+ 'divsoup',
+ [ref4],
+ undefined,
+ undefined,
+ expect.any(Object),
+ expect.any(Object),
),
- routeObj('divsoup', [ref4], undefined, undefined, expect.any(Object)),
]);
});
@@ -349,13 +384,30 @@ describe('discovery', () => {
[ref5, ref3],
]);
expect(info.routeObjects).toEqual([
- routeObj('foo', [ref1, ref2], [], 'mounted', expect.any(Object)),
+ routeObj(
+ 'foo',
+ [ref1, ref2],
+ [],
+ 'mounted',
+ expect.any(Object),
+ expect.any(Object),
+ ),
routeObj(
'bar',
[ref3],
- [routeObj('', [ref4, ref5], [], 'mounted', expect.any(Object))],
+ [
+ routeObj(
+ '',
+ [ref4, ref5],
+ [],
+ 'mounted',
+ expect.any(Object),
+ expect.any(Object),
+ ),
+ ],
'mounted',
expect.any(Object),
+ expect.any(Object),
),
]);
});
@@ -429,18 +481,22 @@ describe('discovery', () => {
undefined,
undefined,
expect.any(Object),
+ expect.any(Object),
),
],
undefined,
expect.any(Object),
+ expect.any(Object),
),
],
'mounted',
expect.any(Object),
+ expect.any(Object),
),
],
undefined,
expect.any(Object),
+ expect.any(Object),
),
]);
});
@@ -499,7 +555,14 @@ describe('discovery', () => {
'r',
[],
[
- routeObj('x', [ref1], [], 'mounted', expect.any(Object)),
+ routeObj(
+ 'x',
+ [ref1],
+ [],
+ 'mounted',
+ expect.any(Object),
+ expect.any(Object),
+ ),
routeObj(
'y',
[],
@@ -514,6 +577,7 @@ describe('discovery', () => {
undefined,
'mounted',
expect.any(Object),
+ expect.any(Object),
),
routeObj(
'b',
@@ -521,15 +585,22 @@ describe('discovery', () => {
undefined,
'mounted',
expect.any(Object),
+ expect.any(Object),
),
],
'mounted',
expect.any(Object),
+ expect.any(Object),
),
],
'mounted',
+ undefined,
+ expect.any(Object),
),
],
+ undefined,
+ undefined,
+ expect.any(Object),
),
]);
});
diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts
index 2bfd09a3e8..17afbeb4c9 100644
--- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts
+++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts
@@ -84,6 +84,7 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
plugins: new Set(),
+ appNode: current,
};
parentChildren.push(currentObj);
diff --git a/packages/frontend-app-api/src/routing/types.ts b/packages/frontend-app-api/src/routing/types.ts
index 5f1ed9be84..05ea73dff8 100644
--- a/packages/frontend-app-api/src/routing/types.ts
+++ b/packages/frontend-app-api/src/routing/types.ts
@@ -15,6 +15,7 @@
*/
import {
+ AppNode,
ExternalRouteRef,
RouteRef,
SubRouteRef,
@@ -35,4 +36,5 @@ export interface BackstageRouteObject {
path: string;
routeRefs: Set;
plugins: Set;
+ appNode?: AppNode;
}
diff --git a/packages/frontend-app-api/src/wiring/InternalAppContext.ts b/packages/frontend-app-api/src/wiring/InternalAppContext.ts
index 805c46a067..81264bd81b 100644
--- a/packages/frontend-app-api/src/wiring/InternalAppContext.ts
+++ b/packages/frontend-app-api/src/wiring/InternalAppContext.ts
@@ -17,10 +17,12 @@
import { createContext } from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
+import { BackstageRouteObject } from '../routing/types';
export const InternalAppContext = createContext<
| undefined
| {
appIdentityProxy: AppIdentityProxy;
+ routeObjects: BackstageRouteObject[];
}
>(undefined);
diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx
index a9400d3ba4..a1412f1c3c 100644
--- a/packages/frontend-app-api/src/wiring/createApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.tsx
@@ -341,7 +341,9 @@ export function createSpecializedApp(options?: {
-
+
{rootEl}
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index c5005d34ac..c1c920559e 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -86,6 +86,51 @@ export { alertApiRef };
export { AlertMessage };
+// @public
+export type AnalyticsApi = {
+ captureEvent(event: AnalyticsEvent): void;
+};
+
+// @public
+export const analyticsApiRef: ApiRef;
+
+// @public
+export const AnalyticsContext: (options: {
+ attributes: Partial;
+ children: ReactNode;
+}) => React_2.JSX.Element;
+
+// @public
+export type AnalyticsContextValue = CommonAnalyticsContext & {
+ [param in string]: string | boolean | number | undefined;
+};
+
+// @public
+export type AnalyticsEvent = {
+ action: string;
+ subject: string;
+ value?: number;
+ attributes?: AnalyticsEventAttributes;
+ context: AnalyticsContextValue;
+};
+
+// @public
+export type AnalyticsEventAttributes = {
+ [attribute in string]: string | boolean | number;
+};
+
+// @public
+export type AnalyticsTracker = {
+ captureEvent: (
+ action: string,
+ subject: string,
+ options?: {
+ value?: number;
+ attributes?: AnalyticsEventAttributes;
+ },
+ ) => void;
+};
+
export { AnyApiFactory };
export { AnyApiRef };
@@ -233,6 +278,12 @@ export { bitbucketAuthApiRef };
export { bitbucketServerAuthApiRef };
+// @public
+export type CommonAnalyticsContext = {
+ pluginId: string;
+ extensionId: string;
+};
+
export { ConfigApi };
export { configApiRef };
@@ -770,6 +821,9 @@ export interface SubRouteRef<
export { TypesToApiRefs };
+// @public
+export function useAnalytics(): AnalyticsTracker;
+
export { useApi };
export { useApiHolder };
diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx
new file mode 100644
index 0000000000..8e78bdc8c4
--- /dev/null
+++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2021 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 React from 'react';
+import { render } from '@testing-library/react';
+import { renderHook } from '@testing-library/react';
+import { AnalyticsContext, useAnalyticsContext } from './AnalyticsContext';
+
+const AnalyticsSpy = () => {
+ const context = useAnalyticsContext();
+ return (
+ <>
+
{context.pluginId}
+
{context.extensionId}
+
{context.custom}
+ >
+ );
+};
+
+describe('AnalyticsContext', () => {
+ describe('useAnalyticsContext', () => {
+ it('returns default values', () => {
+ const { result } = renderHook(() => useAnalyticsContext());
+ expect(result.current).toEqual({
+ extensionId: 'App',
+ pluginId: 'root',
+ });
+ });
+ });
+
+ describe('AnalyticsContext', () => {
+ it('uses default analytics context', () => {
+ const result = render(
+
+
+ ,
+ );
+
+ expect(result.getByTestId('extension-id')).toHaveTextContent('App');
+ expect(result.getByTestId('plugin-id')).toHaveTextContent('root');
+ });
+
+ it('uses provided analytics context', () => {
+ const result = render(
+
+
+ ,
+ );
+
+ expect(result.getByTestId('extension-id')).toHaveTextContent('App');
+ expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
+ });
+
+ it('uses nested analytics context', () => {
+ const result = render(
+
+
+
+
+ ,
+ );
+
+ expect(result.getByTestId('extension-id')).toHaveTextContent(
+ 'AnalyticsSpy',
+ );
+ expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
+ });
+ });
+});
diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx
new file mode 100644
index 0000000000..10ef475803
--- /dev/null
+++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2021 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 {
+ createVersionedContext,
+ createVersionedValueMap,
+} from '@backstage/version-bridge';
+import React, { ReactNode, useContext } from 'react';
+import { AnalyticsContextValue } from './types';
+
+const AnalyticsReactContext = createVersionedContext<{
+ 1: AnalyticsContextValue;
+}>('analytics-context');
+
+/**
+ * A "private" (to this package) hook that enables context inheritance and a
+ * way to read Analytics Context values at event capture-time.
+ *
+ * @internal
+ */
+export const useAnalyticsContext = (): AnalyticsContextValue => {
+ const theContext = useContext(AnalyticsReactContext);
+
+ // Provide a default value if no value exists.
+ if (theContext === undefined) {
+ return {
+ pluginId: 'root',
+ extensionId: 'App',
+ };
+ }
+
+ // This should probably never happen, but check for it.
+ const theValue = theContext.atVersion(1);
+ if (theValue === undefined) {
+ throw new Error('No context found for version 1.');
+ }
+
+ return theValue;
+};
+
+/**
+ * Provides components in the child react tree an Analytics Context, ensuring
+ * all analytics events captured within the context have relevant attributes.
+ *
+ * @remarks
+ *
+ * Analytics contexts are additive, meaning the context ultimately emitted with
+ * an event is the combination of all contexts in the parent tree.
+ *
+ * @public
+ */
+export const AnalyticsContext = (options: {
+ attributes: Partial;
+ children: ReactNode;
+}) => {
+ const { attributes, children } = options;
+
+ const parentValues = useAnalyticsContext();
+ const combinedValue = {
+ ...parentValues,
+ ...attributes,
+ };
+
+ const versionedCombinedValue = createVersionedValueMap({ 1: combinedValue });
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * Returns an HOC wrapping the provided component in an Analytics context with
+ * the given values.
+ *
+ * @param Component - Component to be wrapped with analytics context attributes
+ * @param values - Analytics context key/value pairs.
+ * @internal
+ */
+export function withAnalyticsContext(
+ Component: React.ComponentType,
+ values: AnalyticsContextValue,
+) {
+ const ComponentWithAnalyticsContext = (props: TProps) => {
+ return (
+
+
+
+ );
+ };
+ ComponentWithAnalyticsContext.displayName = `WithAnalyticsContext(${
+ Component.displayName || Component.name || 'Component'
+ })`;
+ return ComponentWithAnalyticsContext;
+}
diff --git a/packages/frontend-plugin-api/src/analytics/Tracker.test.ts b/packages/frontend-plugin-api/src/analytics/Tracker.test.ts
new file mode 100644
index 0000000000..f1670cf9c4
--- /dev/null
+++ b/packages/frontend-plugin-api/src/analytics/Tracker.test.ts
@@ -0,0 +1,285 @@
+/*
+ * 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 = {
+ pluginId: 'root',
+ extensionId: '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({
+ pluginId: 'catalog',
+ extensionId: '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: {
+ pluginId: 'catalog',
+ extensionId: 'App',
+ },
+ }),
+ );
+ });
+
+ describe('accurate navigate events', () => {
+ it('never captures _routeNodeType context key on navigate event', () => {
+ trackerUnderTest.setContext({
+ pluginId: 'catalog',
+ extensionId: 'App',
+ });
+ 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,
+ });
+ 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,
+ });
+ trackerUnderTest.captureEvent('navigate', '/page-5');
+
+ // A routable extension is rendered with specific plugin context
+ trackerUnderTest.setContext({
+ pluginId: 'some-plugin',
+ extensionId: '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: {
+ pluginId: 'some-plugin',
+ extensionId: 'App',
+ },
+ }),
+ );
+ expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ action: 'click',
+ subject: 'test 5',
+ context: {
+ pluginId: 'some-plugin',
+ extensionId: '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,
+ });
+ trackerUnderTest.captureEvent('navigate', '/page-6');
+
+ // A routable extension is rendered with specific plugin context
+ trackerUnderTest.setContext({
+ pluginId: 'another-plugin',
+ extensionId: 'App',
+ });
+ trackerUnderTest.captureEvent(routableExtensionRenderedEvent, '');
+
+ // User navigates to another gathered mountpoint with multiple plugins
+ trackerUnderTest.setContext({
+ ...defaultContext,
+ });
+ trackerUnderTest.captureEvent('navigate', '/page-6-2');
+
+ expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(1);
+ expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ action: 'navigate',
+ subject: '/page-6',
+ context: {
+ pluginId: 'another-plugin',
+ extensionId: '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,
+ });
+ 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,
+ });
+ 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, {
+ pluginId: 'the-plugin',
+ extensionId: '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: {
+ pluginId: 'the-plugin',
+ extensionId: '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,
+ });
+ trackerUnderTest.captureEvent('navigate', '/page-9');
+
+ // A routable extension is rendered with specific plugin context
+ trackerUnderTest.setContext({
+ pluginId: 'very-plugin',
+ extensionId: '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: {
+ pluginId: 'very-plugin',
+ extensionId: 'App',
+ },
+ }),
+ );
+ });
+ });
+});
diff --git a/packages/frontend-plugin-api/src/analytics/Tracker.ts b/packages/frontend-plugin-api/src/analytics/Tracker.ts
new file mode 100644
index 0000000000..dd0d770354
--- /dev/null
+++ b/packages/frontend-plugin-api/src/analytics/Tracker.ts
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2021 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 {
+ AnalyticsApi,
+ AnalyticsEventAttributes,
+ AnalyticsTracker,
+} 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,
+ private context: AnalyticsContextValue = {
+ pluginId: 'root',
+ extensionId: '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;
+ }
+
+ captureEvent(
+ action: string,
+ subject: string,
+ {
+ value,
+ attributes,
+ }: { value?: number; attributes?: AnalyticsEventAttributes } = {},
+ ) {
+ // Never pass internal "_routeNodeType" context value.
+ const 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,
+ extensionId: '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' && 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,
+ });
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('Error during analytics event capture. %o', e);
+ }
+ }
+}
diff --git a/packages/frontend-plugin-api/src/analytics/index.ts b/packages/frontend-plugin-api/src/analytics/index.ts
new file mode 100644
index 0000000000..651e8e105e
--- /dev/null
+++ b/packages/frontend-plugin-api/src/analytics/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 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.
+ */
+
+export { AnalyticsContext } from './AnalyticsContext';
+export type { AnalyticsContextValue, CommonAnalyticsContext } from './types';
+export { useAnalytics } from './useAnalytics';
diff --git a/packages/frontend-plugin-api/src/analytics/types.ts b/packages/frontend-plugin-api/src/analytics/types.ts
new file mode 100644
index 0000000000..21347e2fc7
--- /dev/null
+++ b/packages/frontend-plugin-api/src/analytics/types.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2021 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.
+ */
+
+/**
+ * Common analytics context attributes.
+ *
+ * @public
+ */
+export type CommonAnalyticsContext = {
+ /**
+ * The nearest known parent plugin where the event was captured.
+ */
+ pluginId: string;
+
+ /**
+ * The nearest known parent extension where the event was captured.
+ */
+ /**
+ * The nearest known parent extension where the event was captured.
+ */
+ extensionId: string;
+};
+
+/**
+ * Analytics context envelope.
+ *
+ * @public
+ */
+export type AnalyticsContextValue = CommonAnalyticsContext & {
+ [param in string]: string | boolean | number | undefined;
+};
diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx
new file mode 100644
index 0000000000..beb16b1fbb
--- /dev/null
+++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2021 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 { renderHook } from '@testing-library/react';
+import { useAnalytics } from './useAnalytics';
+import { analyticsApiRef } from '@backstage/core-plugin-api';
+import { TestApiProvider } from '@backstage/test-utils';
+import React from 'react';
+
+describe('useAnalytics', () => {
+ it('returns tracker with no implementation defined', () => {
+ // useApi throws an error because the Analytics API is not implemented
+ // But the result should still have a captureEvent method.
+ const { result } = renderHook(() => useAnalytics(), {});
+ expect(result.current.captureEvent).toBeDefined();
+ });
+
+ it('returns tracker from defined analytics api', () => {
+ const captureEvent = jest.fn();
+
+ // Calling the captureEvent method of the underlying implementation should
+ // pass along the given event as well as the default context.
+ const { result } = renderHook(() => useAnalytics(), {
+ wrapper: ({ children }) => (
+ // Simulate useApi returning a valid tracker.
+
+ {children}
+
+ ),
+ });
+ result.current.captureEvent('an action', 'a subject', {
+ value: 42,
+ attributes: { some: 'value' },
+ });
+ expect(captureEvent).toHaveBeenCalledWith({
+ action: 'an action',
+ subject: 'a subject',
+ value: 42,
+ attributes: {
+ some: 'value',
+ },
+ context: {
+ extensionId: 'App',
+ pluginId: 'root',
+ },
+ });
+ });
+});
diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx
new file mode 100644
index 0000000000..f3b5998de4
--- /dev/null
+++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2021 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 { useApi } from '@backstage/core-plugin-api';
+import { useAnalyticsContext } from './AnalyticsContext';
+import { analyticsApiRef, AnalyticsTracker, AnalyticsApi } from '../apis';
+import { useRef } from 'react';
+import { Tracker } from './Tracker';
+
+function useAnalyticsApi(): AnalyticsApi {
+ try {
+ return useApi(analyticsApiRef);
+ } catch {
+ return { captureEvent: () => {} };
+ }
+}
+
+/**
+ * Gets a pre-configured analytics tracker.
+ *
+ * @public
+ */
+export function useAnalytics(): AnalyticsTracker {
+ const trackerRef = useRef(null);
+ const context = useAnalyticsContext();
+ // Our goal is to make this API truly optional for any/all consuming code
+ // (including tests). This hook runs last to ensure hook order is, as much as
+ // possible, maintained.
+ const analyticsApi = useAnalyticsApi();
+
+ function getTracker(): Tracker {
+ if (trackerRef.current === null) {
+ trackerRef.current = new Tracker(analyticsApi);
+ }
+ return trackerRef.current;
+ }
+
+ const tracker = getTracker();
+ // this is not ideal, but it allows to memoize the tracker
+ // without explicitly set the context as dependency.
+ tracker.setContext(context);
+
+ return tracker;
+}
diff --git a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts
new file mode 100644
index 0000000000..15b09482b7
--- /dev/null
+++ b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2021 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 { ApiRef, createApiRef } from '@backstage/core-plugin-api';
+import { AnalyticsContextValue } from '../../analytics/types';
+
+/**
+ * Represents an event worth tracking in an analytics system that could inform
+ * how users of a Backstage instance are using its features.
+ *
+ * @public
+ */
+export type AnalyticsEvent = {
+ /**
+ * A string that identifies the event being tracked by the type of action the
+ * event represents. Be careful not to encode extra metadata in this string
+ * that should instead be placed in the Analytics Context or attributes.
+ * Examples include:
+ *
+ * - view
+ * - click
+ * - filter
+ * - search
+ * - hover
+ * - scroll
+ */
+ action: string;
+
+ /**
+ * A string that uniquely identifies the object that the action is being
+ * taken on. Examples include:
+ *
+ * - The path of the page viewed
+ * - The url of the link clicked
+ * - The value that was filtered by
+ * - The text that was searched for
+ */
+ subject: string;
+
+ /**
+ * An optional numeric value relevant to the event that could be aggregated
+ * by analytics tools. Examples include:
+ *
+ * - The index or position of the clicked element in an ordered list
+ * - The percentage of an element that has been scrolled through
+ * - The amount of time that has elapsed since a fixed point
+ * - A satisfaction score on a fixed scale
+ */
+ value?: number;
+
+ /**
+ * Optional, additional attributes (representing dimensions or metrics)
+ * specific to the event that could be forwarded on to analytics systems.
+ */
+ attributes?: AnalyticsEventAttributes;
+
+ /**
+ * Contextual metadata relating to where the event was captured and by whom.
+ * This could include information about the route, plugin, or extension in
+ * which an event was captured.
+ */
+ context: AnalyticsContextValue;
+};
+
+/**
+ * A structure allowing other arbitrary metadata to be provided by analytics
+ * event emitters.
+ *
+ * @public
+ */
+export type AnalyticsEventAttributes = {
+ [attribute in string]: string | boolean | number;
+};
+
+/**
+ * Represents a tracker with methods that can be called to track events in a
+ * configured analytics service.
+ *
+ * @public
+ */
+export type AnalyticsTracker = {
+ captureEvent: (
+ action: string,
+ subject: string,
+ options?: {
+ value?: number;
+ attributes?: AnalyticsEventAttributes;
+ },
+ ) => void;
+};
+
+/**
+ * The Analytics API is used to track user behavior in a Backstage instance.
+ *
+ * @remarks
+ *
+ * To instrument your App or Plugin, retrieve an analytics tracker using the
+ * useAnalytics() hook. This will return a pre-configured AnalyticsTracker
+ * with relevant methods for instrumentation.
+ *
+ * @public
+ */
+export type AnalyticsApi = {
+ /**
+ * Primary event handler responsible for compiling and forwarding events to
+ * an analytics system.
+ */
+ captureEvent(event: AnalyticsEvent): void;
+};
+
+/**
+ * The API reference of {@link AnalyticsApi}.
+ *
+ * @public
+ */
+export const analyticsApiRef: ApiRef = createApiRef({
+ id: 'core.analytics',
+});
diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts
index 420942cb19..8791912e4a 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/index.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts
@@ -42,3 +42,4 @@ export * from './FetchApi';
export * from './IdentityApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
+export * from './AnalyticsApi';
diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts
index 3248e79945..10f89b81b5 100644
--- a/packages/frontend-plugin-api/src/index.ts
+++ b/packages/frontend-plugin-api/src/index.ts
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
+export * from './analytics';
export * from './apis';
export * from './components';
export * from './extensions';