diff --git a/packages/frontend-app-api/src/routing/RouteTracker.test.tsx b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx
new file mode 100644
index 0000000000..fa5a4f633a
--- /dev/null
+++ b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx
@@ -0,0 +1,228 @@
+/*
+ * 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 { TestApiProvider } from '@backstage/test-utils';
+import React from 'react';
+import { BackstageRouteObject } from './types';
+import { fireEvent, render } from '@testing-library/react';
+import { RouteTracker } from './RouteTracker';
+import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
+import { createPlugin } from '@backstage/core-plugin-api';
+import {
+ createRouteRef,
+ AnalyticsApi,
+ analyticsApiRef,
+} from '@backstage/frontend-plugin-api';
+import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode';
+
+describe('RouteTracker', () => {
+ const routeRef0 = createRouteRef();
+ const routeRef1 = createRouteRef();
+ const routeRef2 = createRouteRef();
+ const plugin0 = createPlugin({ id: 'home' });
+ const plugin1 = createPlugin({ id: 'plugin1' });
+ const plugin2 = createPlugin({ id: 'plugin2' });
+
+ const routeObjects: BackstageRouteObject[] = [
+ {
+ path: '',
+ element:
home page
,
+ routeRefs: new Set([routeRef0]),
+ plugins: new Set([plugin0]),
+ caseSensitive: false,
+ children: [MATCH_ALL_ROUTE],
+ },
+ {
+ path: '/path/:p1/:p2',
+ element: go,
+ routeRefs: new Set([routeRef1]),
+ plugins: new Set([plugin1]),
+ caseSensitive: false,
+ children: [MATCH_ALL_ROUTE],
+ },
+ {
+ path: '/path2/:param',
+ element: hi there
,
+ routeRefs: new Set([routeRef2]),
+ plugins: new Set([plugin2]),
+ caseSensitive: false,
+ children: [MATCH_ALL_ROUTE],
+ },
+ ];
+
+ const mockedAnalytics: jest.Mocked = {
+ captureEvent: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should capture the navigate event on load', async () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
+ action: 'navigate',
+ attributes: {
+ p1: 'foo',
+ p2: 'bar',
+ },
+ context: {
+ extension: 'App',
+ pluginId: 'plugin1',
+ routeRef: undefined,
+ },
+ subject: '/path/foo/bar',
+ value: undefined,
+ });
+ });
+
+ it('should capture the navigate event on route change', async () => {
+ const { getByText } = render(
+
+
+
+
+
+ {routeObjects.map(({ path, element }) => (
+
+ ))}
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('go'));
+
+ expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
+ action: 'navigate',
+ attributes: {
+ param: 'hello',
+ },
+ context: {
+ extension: 'App',
+ pluginId: 'plugin2',
+ routeRef: undefined,
+ },
+ subject: '/path2/hello',
+ value: undefined,
+ });
+ });
+
+ it('should capture path query and hash', async () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
+ action: 'navigate',
+ attributes: {
+ p1: 'foo',
+ p2: 'bar',
+ },
+ context: {
+ extension: 'App',
+ pluginId: 'plugin1',
+ routeRef: undefined,
+ },
+ subject: '/path/foo/bar?q=1#header-1',
+ value: undefined,
+ });
+ });
+
+ it('should match the root path and send relevant context', async () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
+ action: 'navigate',
+ attributes: {},
+ context: {
+ extension: 'App',
+ pluginId: 'home',
+ routeRef: undefined,
+ },
+ subject: '/',
+ value: undefined,
+ });
+ });
+
+ it('should return default context when it would have otherwise matched on the root path', async () => {
+ render(
+
+
+
+
+ Non-extension>}
+ />
+
+
+ ,
+ );
+
+ expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
+ action: 'navigate',
+ attributes: {},
+ context: {
+ extension: 'App',
+ pluginId: 'root',
+ routeRef: 'unknown',
+ },
+ subject: '/not-routable-extension',
+ 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: {
+ extension: 'App',
+ pluginId: 'plugin2',
+ routeRef: undefined,
+ },
+ 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..a25a12fae2
--- /dev/null
+++ b/packages/frontend-app-api/src/routing/RouteTracker.tsx
@@ -0,0 +1,141 @@
+/*
+ * 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 { RouteRef, BackstagePlugin } from '@backstage/core-plugin-api';
+import {
+ useAnalytics,
+ AnalyticsContext,
+ AnalyticsEventAttributes,
+} from '@backstage/core-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;
+ }
+
+ // 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]) => {
+ if (value !== undefined && key !== '*') {
+ acc[key] = value;
+ }
+ return acc;
+ }, {});
+
+ return {
+ extension: 'App',
+ pluginId: plugin?.getId() || 'root',
+ ...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}),
+ _routeNodeType: routeObject.element as string,
+ params,
+ };
+ } 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/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx
index a9400d3ba4..6f5a1592c2 100644
--- a/packages/frontend-app-api/src/wiring/createApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.tsx
@@ -97,6 +97,7 @@ import { CoreRouter } from '../extensions/CoreRouter';
import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createPlugin';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
+import { RouteTracker } from '../routing/RouteTracker';
const builtinExtensions = [
Core,
@@ -342,6 +343,7 @@ export function createSpecializedApp(options?: {
+
{rootEl}