diff --git a/.changeset/analytics-sings-stormy-weather.md b/.changeset/analytics-sings-stormy-weather.md
new file mode 100644
index 0000000000..2a0d83e8a4
--- /dev/null
+++ b/.changeset/analytics-sings-stormy-weather.md
@@ -0,0 +1,24 @@
+---
+'@backstage/core-app-api': patch
+---
+
+The Core App API now automatically instruments all route location changes using
+the new Analytics API. Each location change triggers a `navigate` event, which
+is an analogue of a "pageview" event in traditional web analytics systems. In
+addition to the path, these events provide plugin-level metadata via the
+analytics domain, which can be useful for analyzing plugin usage:
+
+```json
+{
+ "verb": "navigate",
+ "noun": "/the-path/navigated/to?with=params#and-hashes",
+ "domain": {
+ "componentName": "App",
+ "pluginId": "id-of-plugin-that-exported-the-route",
+ "routeRef": "associated-route-ref-id"
+ }
+}
+```
+
+These events can be identified and handled by checking for the verb `navigate`
+and the componentName `App`.
diff --git a/packages/core-app-api/config.d.ts b/packages/core-app-api/config.d.ts
index d88c818d11..a2fa3f1989 100644
--- a/packages/core-app-api/config.d.ts
+++ b/packages/core-app-api/config.d.ts
@@ -65,6 +65,18 @@ export interface Config {
}>;
}>;
};
+
+ /**
+ * Information about how analytics events should be collected in this
+ * Backstage Instance.
+ */
+ analytics?: {
+ /**
+ * The provider used to collect instrumented events. Further
+ * configuration values depend on the provider specified here.
+ */
+ provider: string;
+ };
};
/**
diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/App.test.tsx
index 6fc1d96fd8..93b711036b 100644
--- a/packages/core-app-api/src/app/App.test.tsx
+++ b/packages/core-app-api/src/app/App.test.tsx
@@ -15,11 +15,15 @@
*/
import { LocalStorageFeatureFlags } from '../apis';
-import { renderWithEffects, withLogCollector } from '@backstage/test-utils';
+import {
+ MockAnalyticsApi,
+ renderWithEffects,
+ withLogCollector,
+} from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { render, screen } from '@testing-library/react';
import React, { PropsWithChildren } from 'react';
-import { BrowserRouter, Routes } from 'react-router-dom';
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { defaultAppIcons } from './icons';
import {
configApiRef,
@@ -31,6 +35,7 @@ import {
createRouteRef,
createSubRouteRef,
createRoutableExtension,
+ analyticsApiRef,
} from '@backstage/core-plugin-api';
import { generateBoundRoutes, PrivateAppImpl } from './App';
import { AppThemeProvider } from './AppThemeProvider';
@@ -59,6 +64,7 @@ describe('generateBoundRoutes', () => {
describe('Integration Test', () => {
const plugin1RouteRef = createRouteRef({ id: 'ref-1' });
+ const plugin1RouteRef2 = createRouteRef({ id: 'ref-1-2' });
const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] });
const subRouteRef1 = createSubRouteRef({
id: 'sub1',
@@ -155,6 +161,16 @@ describe('Integration Test', () => {
}),
);
+ const NavigateComponent = plugin1.provide(
+ createRoutableExtension({
+ component: () =>
+ Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
+ return ;
+ }),
+ mountPoint: plugin1RouteRef2,
+ }),
+ );
+
const components = {
NotFoundErrorPage: () => null,
BootErrorPage: () => null,
@@ -322,6 +338,69 @@ describe('Integration Test', () => {
});
});
+ it('should track route changes via analytics api', async () => {
+ const mockAnalyticsApi = new MockAnalyticsApi();
+ const apis = [createApiFactory(analyticsApiRef, mockAnalyticsApi)];
+ const app = new PrivateAppImpl({
+ apis,
+ defaultApis: [],
+ themes: [
+ {
+ id: 'light',
+ title: 'Light Theme',
+ variant: 'light',
+ theme: lightTheme,
+ },
+ ],
+ icons: defaultAppIcons,
+ plugins: [],
+ components,
+ bindRoutes: ({ bind }) => {
+ bind(plugin1.externalRoutes, {
+ extRouteRef1: plugin1RouteRef,
+ extRouteRef2: plugin2RouteRef,
+ });
+ },
+ });
+
+ const Provider = app.getProvider();
+ const Router = app.getRouter();
+
+ await renderWithEffects(
+
+
+
+ } />
+ } />
+
+
+ ,
+ );
+
+ // Capture initial and subsequent navigation events with expected domain
+ // values.
+ const capturedEvents = mockAnalyticsApi.getEvents();
+ expect(capturedEvents[0]).toMatchObject({
+ verb: 'navigate',
+ noun: '/',
+ domain: {
+ componentName: 'App',
+ pluginId: 'blob',
+ routeRef: 'ref-1-2',
+ },
+ });
+ expect(capturedEvents[1]).toMatchObject({
+ verb: 'navigate',
+ noun: '/foo',
+ domain: {
+ componentName: 'App',
+ pluginId: 'plugin2',
+ routeRef: 'ref-2',
+ },
+ });
+ expect(capturedEvents).toHaveLength(2);
+ });
+
it('should throw some error when the route has duplicate params', () => {
const app = new PrivateAppImpl({
apis: [],
diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx
index f5fec7e624..fb40a83dab 100644
--- a/packages/core-app-api/src/app/App.tsx
+++ b/packages/core-app-api/src/app/App.tsx
@@ -23,7 +23,7 @@ import React, {
useMemo,
useState,
} from 'react';
-import { Route, Routes } from 'react-router-dom';
+import { createRoutesFromChildren, Route, Routes } from 'react-router-dom';
import { useAsync } from 'react-use';
import {
ApiProvider,
@@ -63,6 +63,7 @@ import {
routePathCollector,
} from '../routing/collectors';
import { RoutingProvider } from '../routing/RoutingProvider';
+import { RouteTracker } from '../routing/RouteTracker';
import { validateRoutes } from '../routing/validation';
import { AppContextProvider } from './AppContext';
import { AppIdentity } from './AppIdentity';
@@ -367,6 +368,7 @@ export class PrivateAppImpl implements BackstageApp {
return (
+
{children}>} />
@@ -376,6 +378,7 @@ export class PrivateAppImpl implements BackstageApp {
return (
+
{children}>} />
diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx
new file mode 100644
index 0000000000..64e7643762
--- /dev/null
+++ b/packages/core-app-api/src/routing/RouteTracker.tsx
@@ -0,0 +1,114 @@
+/*
+ * 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, { useEffect } from 'react';
+import {
+ createRoutesFromChildren,
+ matchRoutes,
+ useLocation,
+} from 'react-router-dom';
+import {
+ BackstagePlugin,
+ useAnalytics,
+ getComponentData,
+ AnalyticsDomain,
+ RoutableAnalyticsDomain,
+} from '@backstage/core-plugin-api';
+
+type RouteObjects = ReturnType;
+
+/**
+ * Returns an extension domain given the current pathname and a RouteObject
+ * that defines all registered routes in react.
+ *
+ * If no exact match is found, path parts are stripped away, one-by-one, until
+ * a parent-level path matches a route.
+ */
+const getExtensionDomain = (
+ pathname: string,
+ routes: RouteObjects,
+): RoutableAnalyticsDomain | {} => {
+ const cleanPath = pathname.replace(/\/+$/, '');
+ const matches = matchRoutes(routes, { pathname });
+ const RouteElement = matches
+ ?.filter(match => {
+ const pathsMatch = match.pathname.replace(/\/+$/, '') === cleanPath;
+ const hasRoutableElement = !!(match.route.element as React.ReactElement)
+ ?.props?.element;
+ return pathsMatch && hasRoutableElement;
+ })
+ .pop()?.route?.element;
+ const RoutableElement = (RouteElement as React.ReactElement)?.props?.element;
+
+ if (RoutableElement) {
+ const plugin: BackstagePlugin | undefined = getComponentData(
+ RoutableElement,
+ 'core.plugin',
+ );
+ const mountPoint: { id?: string } | undefined = getComponentData(
+ RoutableElement,
+ 'core.mountPoint',
+ );
+ if (plugin && mountPoint) {
+ return {
+ pluginId: plugin.getId(),
+ componentName: 'App',
+ routeRef: mountPoint?.id || '',
+ };
+ }
+ }
+
+ // Try again, one path-level shallower.
+ const nextLevelPath = cleanPath.split('/').slice(0, -1).join('/');
+ return nextLevelPath !== '' ? getExtensionDomain(nextLevelPath, routes) : {};
+};
+
+/**
+ * Performs the actual event capture on render.
+ */
+const CaptureOnRender = ({
+ pathname,
+ search,
+ hash,
+}: {
+ pathname: string;
+ search: string;
+ hash: string;
+}) => {
+ const analytics = useAnalytics();
+
+ useEffect(() => {
+ analytics.captureEvent('navigate', `${pathname}${search}${hash}`);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pathname, search, hash]);
+
+ return null;
+};
+
+/**
+ * Logs a "navigate" event with appropriate plugin-level analytics domain
+ * attributes each time the user navigates to a page.
+ */
+export const RouteTracker = ({ objects }: { objects: RouteObjects }) => {
+ const { pathname, search, hash } = useLocation();
+ const attributes = getExtensionDomain(pathname, objects);
+
+ return (
+
+
+
+ );
+};