diff --git a/.changeset/analytics-get-along-together.md b/.changeset/analytics-get-along-together.md
new file mode 100644
index 0000000000..b6ad9e3019
--- /dev/null
+++ b/.changeset/analytics-get-along-together.md
@@ -0,0 +1,6 @@
+---
+'@backstage/test-utils': patch
+---
+
+Added a mock implementation of the `AnalyticsApi`, which can be used to make
+assertions about captured analytics events.
diff --git a/.changeset/analytics-glimmering-radio-station.md b/.changeset/analytics-glimmering-radio-station.md
new file mode 100644
index 0000000000..3460474a9d
--- /dev/null
+++ b/.changeset/analytics-glimmering-radio-station.md
@@ -0,0 +1,6 @@
+---
+'@backstage/cli': patch
+---
+
+The `create-plugin` command now passes the extension name via the `name` key
+in `createRoutableExtension()` calls in newly created plugins.
diff --git a/.changeset/analytics-haunts-dismembered-constellations.md b/.changeset/analytics-haunts-dismembered-constellations.md
new file mode 100644
index 0000000000..e7cfc52ce1
--- /dev/null
+++ b/.changeset/analytics-haunts-dismembered-constellations.md
@@ -0,0 +1,25 @@
+---
+'@backstage/core-components': patch
+---
+
+The `` component now automatically instruments all link clicks using
+the new Analytics API. Each click triggers a `click` event, containing the
+text of the link the user clicked on, as well as the location to which the user
+clicked. In addition, these events inherit plugin/extension-level metadata,
+allowing clicks to be attributed to the plugin/extension/route containing the
+link:
+
+```json
+{
+ "action": "click",
+ "subject": "Text content of the link that was clicked",
+ "attributes": {
+ "to": "/value/of-the/to-prop/passed-to-the-link"
+ },
+ "context": {
+ "extension": "ExtensionInWhichTheLinkWasClicked",
+ "pluginId": "plugin-in-which-link-was-clicked",
+ "routeRef": "route-ref-in-which-the-link-was-clicked"
+ }
+}
+```
diff --git a/.changeset/analytics-old-skipping-record.md b/.changeset/analytics-old-skipping-record.md
new file mode 100644
index 0000000000..a0cacdf693
--- /dev/null
+++ b/.changeset/analytics-old-skipping-record.md
@@ -0,0 +1,23 @@
+---
+'@backstage/core-plugin-api': patch
+---
+
+Introducing the Analytics API: a lightweight way for plugins to instrument key
+events that could help inform a Backstage Integrator how their instance of
+Backstage is being used. The API consists of the following:
+
+- `useAnalytics()`, a hook to be used inside plugin components which retrieves
+ an Analytics Tracker.
+- `tracker.captureEvent()`, a method on the tracker used to instrument key
+ events. The method expects an action (the event name) and a subject (a unique
+ identifier of the object the action is being taken on).
+- ``, a way to declaratively attach additional information
+ to any/all events captured in the underlying React tree. There is also a
+ `withAnalyticsContext()` HOC utility.
+- The `tracker.captureEvent()` method also accepts an `attributes` option for
+ providing additional run-time information about an event, as well as a
+ `value` option for capturing a numeric/metric value.
+
+By default, captured events are not sent anywhere. In order to collect and
+redirect events to an analytics system, the `analyticsApi` will need to be
+implemented and instantiated by an App Integrator.
diff --git a/.changeset/analytics-sings-stormy-weather.md b/.changeset/analytics-sings-stormy-weather.md
new file mode 100644
index 0000000000..f3e6becdf5
--- /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 context, which can be useful for analyzing plugin usage:
+
+```json
+{
+ "action": "navigate",
+ "subject": "/the-path/navigated/to?with=params#and-hashes",
+ "context": {
+ "extension": "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 action
+`navigate` and the extension `App`.
diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs
index dcf35fc5d2..86a8ed9507 100644
--- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs
+++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs
@@ -11,6 +11,7 @@ export const {{ pluginVar }} = createPlugin({
export const {{ extensionName }} = {{ pluginVar }}.provide(
createRoutableExtension({
+ name: '{{ extensionName }}',
component: () =>
import('./components/ExampleComponent').then(m => m.ExampleComponent),
mountPoint: rootRouteRef,
diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md
index 4b804c2cc2..2f057b329f 100644
--- a/packages/core-app-api/api-report.md
+++ b/packages/core-app-api/api-report.md
@@ -5,6 +5,8 @@
```ts
import { AlertApi } from '@backstage/core-plugin-api';
import { AlertMessage } from '@backstage/core-plugin-api';
+import { AnalyticsApi } from '@backstage/core-plugin-api';
+import { AnalyticsEvent } from '@backstage/core-plugin-api';
import { AnyApiFactory } from '@backstage/core-plugin-api';
import { AnyApiRef } from '@backstage/core-plugin-api';
import { ApiFactory } from '@backstage/core-plugin-api';
@@ -456,6 +458,14 @@ export class MicrosoftAuth {
}: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T;
}
+// Warning: (ae-missing-release-tag) "NoOpAnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export class NoOpAnalyticsApi implements AnalyticsApi {
+ // (undocumented)
+ captureEvent(_event: AnalyticsEvent): void;
+}
+
// Warning: (ae-missing-release-tag) "OAuth2" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
diff --git a/packages/core-app-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts b/packages/core-app-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts
new file mode 100644
index 0000000000..a3e45006ba
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api';
+
+/**
+ * Base implementation for the AnalyticsApi that does nothing.
+ */
+export class NoOpAnalyticsApi implements AnalyticsApi {
+ captureEvent(_event: AnalyticsEvent): void {}
+}
diff --git a/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts b/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts
new file mode 100644
index 0000000000..6bb27dd6d0
--- /dev/null
+++ b/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 { NoOpAnalyticsApi } from './NoOpAnalyticsApi';
diff --git a/packages/core-app-api/src/apis/implementations/index.ts b/packages/core-app-api/src/apis/implementations/index.ts
index 31f01f2bff..c494f966f2 100644
--- a/packages/core-app-api/src/apis/implementations/index.ts
+++ b/packages/core-app-api/src/apis/implementations/index.ts
@@ -21,6 +21,7 @@
export * from './auth';
export * from './AlertApi';
+export * from './AnalyticsApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/App.test.tsx
index 6fc1d96fd8..7eb4220876 100644
--- a/packages/core-app-api/src/app/App.test.tsx
+++ b/packages/core-app-api/src/app/App.test.tsx
@@ -14,12 +14,16 @@
* limitations under the License.
*/
-import { LocalStorageFeatureFlags } from '../apis';
-import { renderWithEffects, withLogCollector } from '@backstage/test-utils';
+import { LocalStorageFeatureFlags, NoOpAnalyticsApi } from '../apis';
+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';
@@ -58,7 +63,12 @@ describe('generateBoundRoutes', () => {
});
describe('Integration Test', () => {
+ const noOpAnalyticsApi = createApiFactory(
+ analyticsApiRef,
+ new NoOpAnalyticsApi(),
+ );
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 +165,16 @@ describe('Integration Test', () => {
}),
);
+ const NavigateComponent = plugin1.provide(
+ createRoutableExtension({
+ component: () =>
+ Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
+ return ;
+ }),
+ mountPoint: plugin1RouteRef2,
+ }),
+ );
+
const components = {
NotFoundErrorPage: () => null,
BootErrorPage: () => null,
@@ -166,7 +186,7 @@ describe('Integration Test', () => {
it('runs happy paths', async () => {
const app = new PrivateAppImpl({
- apis: [],
+ apis: [noOpAnalyticsApi],
defaultApis: [],
themes: [
{
@@ -220,7 +240,7 @@ describe('Integration Test', () => {
it('runs happy paths without optional routes', async () => {
const app = new PrivateAppImpl({
- apis: [],
+ apis: [noOpAnalyticsApi],
defaultApis: [],
themes: [
{
@@ -266,6 +286,7 @@ describe('Integration Test', () => {
jest.spyOn(storageFlags, 'registerFlag');
const apis = [
+ noOpAnalyticsApi,
createApiFactory({
api: featureFlagsApiRef,
deps: { configApi: configApiRef },
@@ -322,6 +343,68 @@ 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 context.
+ const capturedEvents = mockAnalyticsApi.getEvents();
+ expect(capturedEvents[0]).toMatchObject({
+ action: 'navigate',
+ subject: '/',
+ context: {
+ extension: 'App',
+ pluginId: 'blob',
+ routeRef: 'ref-1-2',
+ },
+ });
+ expect(capturedEvents[1]).toMatchObject({
+ action: 'navigate',
+ subject: '/foo',
+ context: {
+ extension: '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..8cb3f167fb 100644
--- a/packages/core-app-api/src/app/App.tsx
+++ b/packages/core-app-api/src/app/App.tsx
@@ -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/app/defaultApis.ts b/packages/core-app-api/src/app/defaultApis.ts
index 2322f72e8d..d0f7786b4c 100644
--- a/packages/core-app-api/src/app/defaultApis.ts
+++ b/packages/core-app-api/src/app/defaultApis.ts
@@ -16,6 +16,7 @@
import {
AlertApiForwarder,
+ NoOpAnalyticsApi,
ErrorApiForwarder,
ErrorAlerter,
GoogleAuth,
@@ -36,6 +37,7 @@ import {
import {
createApiFactory,
alertApiRef,
+ analyticsApiRef,
errorApiRef,
discoveryApiRef,
oauthRequestApiRef,
@@ -65,6 +67,7 @@ export const defaultApis = [
),
}),
createApiFactory(alertApiRef, new AlertApiForwarder()),
+ createApiFactory(analyticsApiRef, new NoOpAnalyticsApi()),
createApiFactory({
api: errorApiRef,
deps: { alertApi: alertApiRef },
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..b745dd9235
--- /dev/null
+++ b/packages/core-app-api/src/routing/RouteTracker.tsx
@@ -0,0 +1,119 @@
+/*
+ * 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, useMemo } from 'react';
+import { matchRoutes, useLocation } from 'react-router-dom';
+import {
+ useAnalytics,
+ AnalyticsContext,
+ CommonAnalyticsContext,
+ RouteRef,
+} from '@backstage/core-plugin-api';
+import { routeObjectCollector } from './collectors';
+import {
+ childDiscoverer,
+ routeElementDiscoverer,
+ traverseElementTree,
+} from '../extensions/traversal';
+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[],
+): CommonAnalyticsContext | {} => {
+ try {
+ // Find matching routes for the given path name.
+ const matches = matchRoutes(routes, { pathname }) as
+ | { route: BackstageRouteObject }[]
+ | null;
+
+ // Of the matching routes, get the last (e.g. most specific) instance of
+ // the BackstageRouteObject.
+ const routeObject = matches
+ ?.filter(match => match?.route.routeRefs?.size > 0)
+ .pop()?.route;
+
+ // If there is no route object, then allow inheritance of default context.
+ if (!routeObject) {
+ return {};
+ }
+
+ // If there is a single route ref, return it.
+ // todo: get routeRef of rendered gathered mount point(?)
+ let routeRef: RouteRef | undefined;
+ if (routeObject.routeRefs.size === 1) {
+ routeRef = routeObject.routeRefs.values().next().value;
+ }
+
+ return {
+ extension: 'App',
+ pluginId: routeObject.plugin?.getId() || 'root',
+ ...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}),
+ };
+ } catch {
+ return {};
+ }
+};
+
+/**
+ * Performs the actual event capture on render.
+ */
+const TrackNavigation = ({
+ pathname,
+ search,
+ hash,
+}: {
+ pathname: string;
+ search: string;
+ hash: string;
+}) => {
+ const analytics = useAnalytics();
+
+ useEffect(() => {
+ analytics.captureEvent('navigate', `${pathname}${search}${hash}`);
+ }, [analytics, pathname, search, hash]);
+
+ return null;
+};
+
+/**
+ * Logs a "navigate" event with appropriate plugin-level analytics context
+ * attributes each time the user navigates to a page.
+ */
+export const RouteTracker = ({ tree }: { tree: React.ReactNode }) => {
+ const { pathname, search, hash } = useLocation();
+ // todo(iamEAP): Work this into the existing traversal and make the data
+ // available on the provider. Then grab from app instance on the router.
+ const { routeObjects } = useMemo(() => {
+ return traverseElementTree({
+ root: tree,
+ discoverers: [childDiscoverer, routeElementDiscoverer],
+ collectors: {
+ routeObjects: routeObjectCollector,
+ },
+ });
+ }, [tree]);
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/core-app-api/src/routing/collectors.test.tsx b/packages/core-app-api/src/routing/collectors.test.tsx
index 36d99d2938..5e8e22051d 100644
--- a/packages/core-app-api/src/routing/collectors.test.tsx
+++ b/packages/core-app-api/src/routing/collectors.test.tsx
@@ -32,6 +32,7 @@ import {
createPlugin,
RouteRef,
attachComponentData,
+ BackstagePlugin,
} from '@backstage/core-plugin-api';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
@@ -98,6 +99,7 @@ function routeObj(
refs: RouteRef[],
children: any[] = [],
type: 'mounted' | 'gathered' = 'mounted',
+ backstagePlugin?: BackstagePlugin,
) {
return {
path: path,
@@ -113,6 +115,7 @@ function routeObj(
},
...children,
],
+ plugin: backstagePlugin,
};
}
@@ -186,7 +189,7 @@ describe('discovery', () => {
routeObj('/blop', [ref5]),
],
),
- routeObj('/divsoup', [ref4]),
+ routeObj('/divsoup', [ref4], undefined, undefined, plugin),
]);
});
diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx
index 0ba83e90f8..320f1a66d8 100644
--- a/packages/core-app-api/src/routing/collectors.tsx
+++ b/packages/core-app-api/src/routing/collectors.tsx
@@ -15,7 +15,11 @@
*/
import { isValidElement, ReactElement, ReactNode } from 'react';
-import { RouteRef, getComponentData } from '@backstage/core-plugin-api';
+import {
+ RouteRef,
+ getComponentData,
+ BackstagePlugin,
+} from '@backstage/core-plugin-api';
import { BackstageRouteObject } from './types';
import { createCollector } from '../extensions/traversal';
import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged';
@@ -141,6 +145,10 @@ export const routeObjectCollector = createCollector(
element: 'mounted',
routeRefs: new Set([routeRef]),
children: [MATCH_ALL_ROUTE],
+ plugin: getComponentData(
+ node.props.element,
+ 'core.plugin',
+ ),
};
parentChildren.push(newObject);
return newObject;
@@ -163,6 +171,7 @@ export const routeObjectCollector = createCollector(
element: 'gathered',
routeRefs: new Set(),
children: [MATCH_ALL_ROUTE],
+ plugin: parentObj?.plugin,
};
parentChildren.push(newObject);
return newObject;
diff --git a/packages/core-app-api/src/routing/types.ts b/packages/core-app-api/src/routing/types.ts
index 12bf0d1a0f..e2cf665982 100644
--- a/packages/core-app-api/src/routing/types.ts
+++ b/packages/core-app-api/src/routing/types.ts
@@ -18,6 +18,7 @@ import {
RouteRef,
SubRouteRef,
ExternalRouteRef,
+ BackstagePlugin,
} from '@backstage/core-plugin-api';
import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
@@ -53,6 +54,7 @@ export interface BackstageRouteObject {
element: React.ReactNode;
path: string;
routeRefs: Set;
+ plugin?: BackstagePlugin;
}
export function isRouteRef(
diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx
index 97a71c5760..df1bcfac03 100644
--- a/packages/core-components/src/components/Link/Link.test.tsx
+++ b/packages/core-components/src/components/Link/Link.test.tsx
@@ -15,11 +15,12 @@
*/
import React from 'react';
-import { render, fireEvent } from '@testing-library/react';
-import { wrapInTestApp } from '@backstage/test-utils';
+import { render, fireEvent, waitFor } from '@testing-library/react';
+import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils';
+import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
+import { analyticsApiRef } from '@backstage/core-plugin-api';
import { isExternalUri, Link } from './Link';
import { Route, Routes } from 'react-router';
-import { act } from 'react-dom/test-utils';
describe('', () => {
it('navigates using react-router', async () => {
@@ -34,10 +35,42 @@ describe('', () => {
),
);
expect(() => getByText(testString)).toThrow();
- await act(async () => {
- fireEvent.click(getByText(linkText));
+ fireEvent.click(getByText(linkText));
+ await waitFor(() => {
+ expect(getByText(testString)).toBeInTheDocument();
+ });
+ });
+
+ it('captures click using analytics api', async () => {
+ const linkText = 'Navigate!';
+ const analyticsApi = new MockAnalyticsApi();
+ const customOnClick = jest.fn();
+
+ const { getByText } = render(
+ wrapInTestApp(
+
+
+ {linkText}
+
+ ,
+ ),
+ );
+
+ fireEvent.click(getByText(linkText));
+
+ // Analytics event should have been fired.
+ await waitFor(() => {
+ expect(analyticsApi.getEvents()[0]).toMatchObject({
+ action: 'click',
+ subject: linkText,
+ attributes: {
+ to: '/test',
+ },
+ });
+
+ // Custom onClick handler should have still been fired too.
+ expect(customOnClick).toHaveBeenCalled();
});
- expect(getByText(testString)).toBeInTheDocument();
});
describe('isExternalUri', () => {
diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx
index 0d301dccf9..d20773a8bb 100644
--- a/packages/core-components/src/components/Link/Link.tsx
+++ b/packages/core-components/src/components/Link/Link.tsx
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+import { useAnalytics } from '@backstage/core-plugin-api';
import {
Link as MaterialLink,
LinkProps as MaterialLinkProps,
@@ -34,26 +35,66 @@ export type LinkProps = MaterialLinkProps &
declare function LinkType(props: LinkProps): JSX.Element;
/**
- * Thin wrapper on top of material-ui's Link component
- * Makes the Link to utilise react-router
+ * Given a react node, try to retrieve its text content.
*/
-const ActualLink = React.forwardRef((props, ref) => {
- const to = String(props.to);
- const external = isExternalUri(to);
- const newWindow = external && !!/^https?:/.exec(to);
- return external ? (
- // External links
-
- ) : (
- // Interact with React Router for internal links
-
- );
-});
+const getNodeText = (node: React.ReactNode): string => {
+ // If the node is an array of children, recurse and join.
+ if (node instanceof Array) {
+ return node.map(getNodeText).join(' ').trim();
+ }
+
+ // If the node is a react element, recurse on its children.
+ if (typeof node === 'object' && node) {
+ return getNodeText((node as React.ReactElement)?.props?.children);
+ }
+
+ // Base case: the node is just text. Return it.
+ if (['string', 'number'].includes(typeof node)) {
+ return String(node);
+ }
+
+ // Base case: just return an empty string.
+ return '';
+};
+
+/**
+ * Thin wrapper on top of material-ui's Link component, which...
+ * - Makes the Link use react-router
+ * - Captures Link clicks as analytics events.
+ */
+const ActualLink = React.forwardRef(
+ ({ onClick, ...props }, ref) => {
+ const analytics = useAnalytics();
+ const to = String(props.to);
+ const linkText = getNodeText(props.children) || to;
+ const external = isExternalUri(to);
+ const newWindow = external && !!/^https?:/.exec(to);
+
+ const handleClick = (event: React.MouseEvent) => {
+ onClick?.(event);
+ analytics.captureEvent('click', linkText, { attributes: { to } });
+ };
+
+ return external ? (
+ // External links
+
+ ) : (
+ // Interact with React Router for internal links
+
+ );
+ },
+);
// TODO(Rugvip): We use this as a workaround to make the exported type be a
// function, which makes our API reference docs much nicer.
diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md
index f92dbd44f0..48ac4f7156 100644
--- a/packages/core-plugin-api/api-report.md
+++ b/packages/core-plugin-api/api-report.md
@@ -34,6 +34,74 @@ export type AlertMessage = {
severity?: 'success' | 'info' | 'warning' | 'error';
};
+// Warning: (ae-missing-release-tag) "AnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type AnalyticsApi = {
+ captureEvent(event: AnalyticsEvent): void;
+};
+
+// Warning: (ae-missing-release-tag) "analyticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const analyticsApiRef: ApiRef;
+
+// Warning: (ae-missing-release-tag) "AnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export const AnalyticsContext: ({
+ attributes,
+ children,
+}: {
+ attributes: Partial;
+ children: ReactNode;
+}) => JSX.Element;
+
+// Warning: (ae-missing-release-tag) "AnalyticsContextValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type AnalyticsContextValue = CommonAnalyticsContext &
+ AnyAnalyticsContext;
+
+// Warning: (ae-missing-release-tag) "AnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type AnalyticsEvent = {
+ action: string;
+ subject: string;
+ value?: number;
+ attributes?: AnalyticsEventAttributes;
+ context: AnalyticsContextValue;
+};
+
+// Warning: (ae-missing-release-tag) "AnalyticsEventAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type AnalyticsEventAttributes = {
+ [attribute in string]: string | boolean | number;
+};
+
+// Warning: (ae-missing-release-tag) "AnalyticsTracker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type AnalyticsTracker = {
+ captureEvent: (
+ action: string,
+ subject: string,
+ options?: {
+ value?: number;
+ attributes?: AnalyticsEventAttributes;
+ },
+ ) => void;
+};
+
+// Warning: (ae-missing-release-tag) "AnyAnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type AnyAnalyticsContext = {
+ [param in string]: string | boolean | number | undefined;
+};
+
// Warning: (ae-missing-release-tag) "AnyApiFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -234,6 +302,15 @@ export type BootErrorPageProps = {
error: Error;
};
+// Warning: (ae-missing-release-tag) "CommonAnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type CommonAnalyticsContext = {
+ pluginId: string;
+ routeRef: string;
+ extension: string;
+};
+
// Warning: (ae-missing-release-tag) "ConfigApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
@@ -273,7 +350,7 @@ export function createApiRef(config: ApiRefConfig): ApiRef;
// @public (undocumented)
export function createComponentExtension<
T extends (props: any) => JSX.Element | null,
->(options: { component: ComponentLoader }): Extension;
+>(options: { component: ComponentLoader; name?: string }): Extension;
// Warning: (ae-forgotten-export) The symbol "OptionalParams" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createExternalRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -309,6 +386,7 @@ export function createReactExtension<
>(options: {
component: ComponentLoader;
data?: Record;
+ name?: string;
}): Extension;
// Warning: (ae-missing-release-tag) "createRoutableExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -316,7 +394,11 @@ export function createReactExtension<
// @public (undocumented)
export function createRoutableExtension<
T extends (props: any) => JSX.Element | null,
->(options: { component: () => Promise; mountPoint: RouteRef }): Extension;
+>(options: {
+ component: () => Promise;
+ mountPoint: RouteRef;
+ name?: string;
+}): Extension;
// Warning: (ae-missing-release-tag) "createRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -832,6 +914,11 @@ export type TypesToApiRefs = {
[key in keyof T]: ApiRef;
};
+// Warning: (ae-missing-release-tag) "useAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export function useAnalytics(): AnalyticsTracker;
+
// Warning: (ae-missing-release-tag) "useApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -900,7 +987,7 @@ export function withApis(apis: TypesToApiRefs):
(
// src/apis/definitions/auth.d.ts:96:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration
// src/apis/definitions/auth.d.ts:110:16 - (tsdoc-undefined-tag) The TSDoc tag "@IdentityApi" is not defined in this configuration
// src/apis/definitions/auth.d.ts:113:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration
-// src/extensions/extensions.d.ts:14:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts
+// src/extensions/extensions.d.ts:15:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts
// src/routing/RouteRef.d.ts:35:5 - (ae-forgotten-export) The symbol "OldIconComponent" needs to be exported by the entry point index.d.ts
// src/routing/types.d.ts:30:5 - (ae-forgotten-export) The symbol "ParamKeys" needs to be exported by the entry point index.d.ts
```
diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx
new file mode 100644
index 0000000000..88e8afa6ef
--- /dev/null
+++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx
@@ -0,0 +1,85 @@
+/*
+ * 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-hooks';
+import { AnalyticsContext, useAnalyticsContext } from './AnalyticsContext';
+
+const AnalyticsSpy = () => {
+ const context = useAnalyticsContext();
+ return (
+ <>
+
{context.routeRef}
+
{context.pluginId}
+
{context.extension}
+
{context.custom}
+ >
+ );
+};
+
+describe('AnalyticsContext', () => {
+ describe('useAnalyticsContext', () => {
+ it('returns default values', () => {
+ const { result } = renderHook(() => useAnalyticsContext());
+ expect(result.current).toEqual({
+ extension: 'App',
+ pluginId: 'root',
+ routeRef: 'unknown',
+ });
+ });
+ });
+
+ describe('AnalyticsContext', () => {
+ it('uses default analytics context', () => {
+ const result = render(
+
+
+ ,
+ );
+
+ expect(result.getByTestId('extension')).toHaveTextContent('App');
+ expect(result.getByTestId('plugin-id')).toHaveTextContent('root');
+ expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
+ });
+
+ it('uses provided analytics context', () => {
+ const result = render(
+
+
+ ,
+ );
+
+ expect(result.getByTestId('extension')).toHaveTextContent('App');
+ expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
+ expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
+ });
+
+ it('uses nested analytics context', () => {
+ const result = render(
+
+
+
+
+ ,
+ );
+
+ expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy');
+ expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
+ expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
+ });
+ });
+});
diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx
new file mode 100644
index 0000000000..f097fd577f
--- /dev/null
+++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx
@@ -0,0 +1,85 @@
+/*
+ * 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, { createContext, ReactNode, useContext } from 'react';
+import { AnalyticsContextValue } from './types';
+
+// todo(iamEAP): Manage this using a version bridge.
+const AnalyticsReactContext = createContext({
+ routeRef: 'unknown',
+ pluginId: 'root',
+ extension: 'App',
+});
+
+/**
+ * A "private" (to this package) hook that enables context inheritance and a
+ * way to read Analytics Context values at event capture-time.
+ * @private
+ */
+export const useAnalyticsContext = () => {
+ return useContext(AnalyticsReactContext);
+};
+
+/**
+ * Provides components in the child react tree an Analytics Context, ensuring
+ * all analytics events captured within the context have relevant attributes.
+ *
+ * Analytics contexts are additive, meaning the context ultimately emitted with
+ * an event is the combination of all contexts in the parent tree.
+ */
+export const AnalyticsContext = ({
+ attributes,
+ children,
+}: {
+ attributes: Partial;
+ children: ReactNode;
+}) => {
+ const parentValues = useAnalyticsContext();
+ const combinedValue = {
+ ...parentValues,
+ ...attributes,
+ };
+
+ 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.
+ */
+export function withAnalyticsContext
(
+ Component: React.ComponentType
,
+ values: AnalyticsContextValue,
+) {
+ const ComponentWithAnalyticsContext = (props: P) => {
+ return (
+
+
+
+ );
+ };
+ ComponentWithAnalyticsContext.displayName = `WithAnalyticsContext(${
+ Component.displayName || Component.name || 'Component'
+ })`;
+ return ComponentWithAnalyticsContext;
+}
diff --git a/packages/core-plugin-api/src/analytics/Tracker.ts b/packages/core-plugin-api/src/analytics/Tracker.ts
new file mode 100644
index 0000000000..51e99bff14
--- /dev/null
+++ b/packages/core-plugin-api/src/analytics/Tracker.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 {
+ AnalyticsApi,
+ AnalyticsEventAttributes,
+ AnalyticsTracker,
+} from '../apis';
+import { AnalyticsContextValue } from './';
+
+export class Tracker implements AnalyticsTracker {
+ constructor(
+ private readonly analyticsApi: AnalyticsApi,
+ private context: AnalyticsContextValue = {
+ routeRef: 'unknown',
+ pluginId: 'root',
+ extension: 'App',
+ },
+ ) {}
+
+ setContext(context: AnalyticsContextValue) {
+ this.context = context;
+ }
+
+ captureEvent(
+ action: string,
+ subject: string,
+ {
+ value,
+ attributes,
+ }: { value?: number; attributes?: AnalyticsEventAttributes } = {},
+ ) {
+ try {
+ this.analyticsApi.captureEvent({
+ action,
+ subject,
+ value,
+ attributes,
+ context: this.context,
+ });
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('Error during analytics event capture. %o', e);
+ }
+ }
+}
diff --git a/packages/core-plugin-api/src/analytics/index.ts b/packages/core-plugin-api/src/analytics/index.ts
new file mode 100644
index 0000000000..942df56ef8
--- /dev/null
+++ b/packages/core-plugin-api/src/analytics/index.ts
@@ -0,0 +1,23 @@
+/*
+ * 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,
+ AnyAnalyticsContext,
+ CommonAnalyticsContext,
+} from './types';
+export { useAnalytics } from './useAnalytics';
diff --git a/packages/core-plugin-api/src/analytics/types.ts b/packages/core-plugin-api/src/analytics/types.ts
new file mode 100644
index 0000000000..ea6c3b030b
--- /dev/null
+++ b/packages/core-plugin-api/src/analytics/types.ts
@@ -0,0 +1,48 @@
+/*
+ * 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.
+ */
+export type CommonAnalyticsContext = {
+ /**
+ * The nearest known parent plugin where the event was captured.
+ */
+ pluginId: string;
+
+ /**
+ * The ID of the routeRef that was active when the event was captured.
+ */
+ routeRef: string;
+
+ /**
+ * The nearest known parent extension where the event was captured.
+ */
+ extension: string;
+};
+
+/**
+ * Allow arbitrary scalar values as context attributes too.
+ */
+export type AnyAnalyticsContext = {
+ [param in string]: string | boolean | number | undefined;
+};
+
+/**
+ * Analytics context envelope.
+ */
+export type AnalyticsContextValue = CommonAnalyticsContext &
+ AnyAnalyticsContext;
diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx
new file mode 100644
index 0000000000..22021026af
--- /dev/null
+++ b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx
@@ -0,0 +1,64 @@
+/*
+ * 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-hooks';
+import { useAnalytics } from './useAnalytics';
+import { useApi } from '../apis';
+
+jest.mock('../apis');
+
+const mocked = (f: Function) => f as jest.Mock;
+
+describe('useAnalytics', () => {
+ it('returns tracker with no implementation defined', () => {
+ // Simulate useApi() throwing an error.
+ mocked(useApi).mockImplementation(() => {
+ throw new Error();
+ });
+
+ // 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();
+
+ // Simulate useApi returning a valid tracker.
+ mocked(useApi).mockReturnValue({ captureEvent });
+
+ // Calling the captureEvent method of the underlying implementation should
+ // pass along the given event as well as the default context.
+ const { result } = renderHook(() => useAnalytics());
+ 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: {
+ extension: 'App',
+ pluginId: 'root',
+ routeRef: 'unknown',
+ },
+ });
+ });
+});
diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx
new file mode 100644
index 0000000000..02d1ecc3c3
--- /dev/null
+++ b/packages/core-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 { useAnalyticsContext } from './AnalyticsContext';
+import {
+ analyticsApiRef,
+ AnalyticsTracker,
+ AnalyticsApi,
+ useApi,
+} from '../apis';
+import { useRef } from 'react';
+import { Tracker } from './Tracker';
+
+function useAnalyticsApi(): AnalyticsApi {
+ try {
+ return useApi(analyticsApiRef);
+ } catch {
+ return { captureEvent: () => {} };
+ }
+}
+
+/**
+ * Get a pre-configured analytics tracker.
+ */
+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();
+ tracker.setContext(context);
+
+ return tracker;
+}
diff --git a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts
new file mode 100644
index 0000000000..43e35607a3
--- /dev/null
+++ b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts
@@ -0,0 +1,116 @@
+/*
+ * 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 '../system';
+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.
+ */
+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.
+ */
+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.
+ */
+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.
+ *
+ * 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.
+ */
+export type AnalyticsApi = {
+ /**
+ * Primary event handler responsible for compiling and forwarding events to
+ * an analytics system.
+ */
+ captureEvent(event: AnalyticsEvent): void;
+};
+
+export const analyticsApiRef: ApiRef = createApiRef({
+ id: 'core.analytics',
+});
diff --git a/packages/core-plugin-api/src/apis/definitions/index.ts b/packages/core-plugin-api/src/apis/definitions/index.ts
index d4350ddbf6..b7666bcb54 100644
--- a/packages/core-plugin-api/src/apis/definitions/index.ts
+++ b/packages/core-plugin-api/src/apis/definitions/index.ts
@@ -23,6 +23,7 @@
export * from './auth';
export * from './AlertApi';
+export * from './AnalyticsApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
diff --git a/packages/core-plugin-api/src/extensions/extensions.test.tsx b/packages/core-plugin-api/src/extensions/extensions.test.tsx
index e8b9d9c534..06b77c248c 100644
--- a/packages/core-plugin-api/src/extensions/extensions.test.tsx
+++ b/packages/core-plugin-api/src/extensions/extensions.test.tsx
@@ -17,6 +17,7 @@
import { withLogCollector } from '@backstage/test-utils-core';
import { render, screen } from '@testing-library/react';
import React from 'react';
+import { useAnalyticsContext } from '../analytics/AnalyticsContext';
import { useApp, ErrorBoundaryFallbackProps } from '../app';
import { createPlugin } from '../plugin';
import { createRouteRef } from '../routing';
@@ -107,4 +108,32 @@ describe('extensions', () => {
screen.getByText('Error in my-plugin');
expect(errors[0]).toMatch('Test error');
});
+
+ it('should wrap extended component with analytics context', async () => {
+ const AnalyticsSpyExtension = plugin.provide(
+ createReactExtension({
+ name: 'AnalyticsSpy',
+ component: {
+ sync: () => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const context = useAnalyticsContext();
+ return (
+ <>
+
{context.pluginId}
+
{context.routeRef}
+
{context.extension}
+ >
+ );
+ },
+ },
+ data: { 'core.mountPoint': { id: 'some-ref' } },
+ }),
+ );
+
+ const result = render();
+
+ expect(result.getByTestId('plugin-id')).toHaveTextContent('my-plugin');
+ expect(result.getByTestId('route-ref')).toHaveTextContent('some-ref');
+ expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy');
+ });
});
diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx
index 73b927afc8..4aff027f31 100644
--- a/packages/core-plugin-api/src/extensions/extensions.tsx
+++ b/packages/core-plugin-api/src/extensions/extensions.tsx
@@ -15,6 +15,7 @@
*/
import React, { lazy, Suspense } from 'react';
+import { AnalyticsContext } from '../analytics/AnalyticsContext';
import { useApp } from '../app';
import { RouteRef, useRouteRef } from '../routing';
import { attachComponentData } from './componentData';
@@ -37,6 +38,7 @@ export function createRoutableExtension<
>(options: {
component: () => Promise;
mountPoint: RouteRef;
+ name?: string;
}): Extension {
const { component, mountPoint } = options;
return createReactExtension({
@@ -62,6 +64,7 @@ export function createRoutableExtension<
};
const componentName =
+ options.name ||
(InnerComponent as { displayName?: string }).displayName ||
InnerComponent.name ||
'LazyComponent';
@@ -84,6 +87,7 @@ export function createRoutableExtension<
data: {
'core.mountPoint': mountPoint,
},
+ name: options.name,
});
}
@@ -92,9 +96,9 @@ export function createRoutableExtension<
// making it impossible to make the usage of children type safe.
export function createComponentExtension<
T extends (props: any) => JSX.Element | null,
->(options: { component: ComponentLoader }): Extension {
- const { component } = options;
- return createReactExtension({ component });
+>(options: { component: ComponentLoader; name?: string }): Extension {
+ const { component, name } = options;
+ return createReactExtension({ component, name });
}
// We do not use ComponentType as the return type, since it doesn't let us convey the children prop.
@@ -105,6 +109,7 @@ export function createReactExtension<
>(options: {
component: ComponentLoader;
data?: Record;
+ name?: string;
}): Extension {
const { data = {} } = options;
@@ -118,6 +123,7 @@ export function createReactExtension<
Component = options.component.sync;
}
const componentName =
+ options.name ||
(Component as { displayName?: string }).displayName ||
Component.name ||
'Component';
@@ -127,11 +133,24 @@ export function createReactExtension<
const Result: any = (props: any) => {
const app = useApp();
const { Progress } = app.getComponents();
+ // todo(iamEAP): Account for situations where this is attached via
+ // separate calls to attachComponentData().
+ const mountPoint = data?.['core.mountPoint'] as
+ | { id?: string }
+ | undefined;
return (
}>
-
+
+
+
);
diff --git a/packages/core-plugin-api/src/index.ts b/packages/core-plugin-api/src/index.ts
index f0c1069652..782416c4d0 100644
--- a/packages/core-plugin-api/src/index.ts
+++ b/packages/core-plugin-api/src/index.ts
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
+export * from './analytics';
export * from './apis';
export * from './app';
export * from './extensions';
diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md
index 214c26c825..f4b3fe12bb 100644
--- a/packages/test-utils/api-report.md
+++ b/packages/test-utils/api-report.md
@@ -3,6 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
+import { AnalyticsApi } from '@backstage/core-plugin-api';
+import { AnalyticsEvent } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { ErrorApi } from '@backstage/core-plugin-api';
import { ErrorContext } from '@backstage/core-plugin-api';
@@ -15,6 +17,22 @@ import { RouteRef } from '@backstage/core-plugin-api';
import { StorageApi } from '@backstage/core-plugin-api';
import { StorageValueChange } from '@backstage/core-plugin-api';
+// Warning: (ae-missing-release-tag) "MockAnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export class MockAnalyticsApi implements AnalyticsApi {
+ // (undocumented)
+ captureEvent({
+ action,
+ subject,
+ value,
+ attributes,
+ context,
+ }: AnalyticsEvent): void;
+ // (undocumented)
+ getEvents(): AnalyticsEvent[];
+}
+
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (ae-missing-release-tag) "mockBreakpoint" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts
new file mode 100644
index 0000000000..3593774a01
--- /dev/null
+++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts
@@ -0,0 +1,65 @@
+/*
+ * 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 { MockAnalyticsApi } from './MockAnalyticsApi';
+
+describe('MockAnalyticsApi', () => {
+ const context = {
+ pluginId: 'some-plugin',
+ routeRef: 'some-route-ref',
+ extension: 'some-extension',
+ };
+
+ it('should collect events', () => {
+ const api = new MockAnalyticsApi();
+
+ api.captureEvent({ action: 'action-1', subject: 'subject-1', context });
+ api.captureEvent({
+ action: 'action-2',
+ subject: 'subject-2',
+ value: 42,
+ context,
+ });
+ api.captureEvent({
+ action: 'action-3',
+ subject: 'subject-3',
+ value: 1337,
+ attributes: { some: 'context' },
+ context,
+ });
+
+ expect(api.getEvents()[0]).toMatchObject({
+ subject: 'subject-1',
+ action: 'action-1',
+ context,
+ });
+ expect(api.getEvents()[1]).toMatchObject({
+ subject: 'subject-2',
+ action: 'action-2',
+ value: 42,
+ context,
+ });
+ expect(api.getEvents()[2]).toMatchObject({
+ subject: 'subject-3',
+ action: 'action-3',
+ value: 1337,
+ context,
+ attributes: {
+ some: 'context',
+ },
+ });
+ });
+});
diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts
new file mode 100644
index 0000000000..28585145ad
--- /dev/null
+++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api';
+
+export class MockAnalyticsApi implements AnalyticsApi {
+ private events: AnalyticsEvent[] = [];
+
+ captureEvent({
+ action,
+ subject,
+ value,
+ attributes,
+ context,
+ }: AnalyticsEvent) {
+ this.events.push({
+ action,
+ subject,
+ context,
+ ...(value !== undefined ? { value } : {}),
+ ...(attributes !== undefined ? { attributes } : {}),
+ });
+ }
+
+ getEvents(): AnalyticsEvent[] {
+ return this.events;
+ }
+}
diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/index.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/index.ts
new file mode 100644
index 0000000000..dc5fd062aa
--- /dev/null
+++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 { MockAnalyticsApi } from './MockAnalyticsApi';
diff --git a/packages/test-utils/src/testUtils/apis/index.ts b/packages/test-utils/src/testUtils/apis/index.ts
index 7bc1f74dcd..44b423a264 100644
--- a/packages/test-utils/src/testUtils/apis/index.ts
+++ b/packages/test-utils/src/testUtils/apis/index.ts
@@ -14,5 +14,6 @@
* limitations under the License.
*/
+export * from './AnalyticsApi';
export * from './ErrorApi';
export * from './StorageApi';
diff --git a/plugins/analytics-module-ga/.eslintrc.js b/plugins/analytics-module-ga/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/plugins/analytics-module-ga/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/plugins/analytics-module-ga/README.md b/plugins/analytics-module-ga/README.md
new file mode 100644
index 0000000000..a0c4fe517a
--- /dev/null
+++ b/plugins/analytics-module-ga/README.md
@@ -0,0 +1,149 @@
+# Analytics Module: Google Analytics
+
+This plugin provides an opinionated implementation of the Backstage Analytics
+API for Google Analytics. Once installed and configured, analytics events will
+be sent to GA as your users navigate and use your Backstage instance.
+
+This plugin contains no other functionality.
+
+## Installation
+
+1. Install the plugin package in your Backstage app:
+ `cd packages/app && yarn add @backstage/plugin-analytics-module-ga`
+2. Wire up the API implementation to your App:
+
+```tsx
+// packages/app/src/apis.ts
+import { analyticsApiRef, configApiRef } from '@backstage/core-plugin-api';
+import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga';
+
+export const apis: AnyApiFactory[] = [
+ // Instantiate and register the GA Analytics API Implementation.
+ createApiFactory({
+ api: analyticsApiRef,
+ deps: { configApi: configApiRef },
+ factory: ({ configApi }) => GoogleAnalytics.fromConfig(configApi),
+ }),
+];
+```
+
+3. Configure the plugin in your `app-config.yaml`:
+
+The following is the minimum configuration required to start sending analytics
+events to GA. All that's needed is your Universal Analytics tracking ID:
+
+```yaml
+# app-config.yaml
+app:
+ analytics:
+ ga:
+ trackingId: UA-0000000-0
+```
+
+## Configuration
+
+In order to be able to analyze usage of your Backstage instance _by plugin_, we
+strongly recommend configuring at least one [custom dimension][what-is-a-custom-dimension]
+to capture Plugin IDs associated with events, including page views.
+
+1. First, [configure the custom dimension in GA][configure-custom-dimension].
+ Be sure to set the Scope to `hit`, and name it something like `Plugin`. Note
+ the index of the dimension you just created (e.g. `1`, if this is the first
+ custom dimension you've created in your GA property).
+2. Then, add a mapping to your `app.analytics.ga` configuration that instructs
+ the plugin to capture Plugin IDs on the custom dimension you just created.
+ It should look like this:
+
+```yaml
+app:
+ analytics:
+ ga:
+ trackingId: UA-0000000-0
+ customDimensionsMetrics:
+ - type: dimension
+ index: 1
+ source: context
+ key: pluginId
+```
+
+You can configure additional custom dimension and metric collection by adding
+more entries to the `customDimensionsMetrics` array:
+
+```yaml
+app:
+ analytics:
+ ga:
+ customDimensionsMetrics:
+ - type: dimension
+ index: 1
+ source: context
+ key: pluginId
+ - type: dimension
+ index: 2
+ source: context
+ key: routeRef
+ - type: dimension
+ index: 3
+ source: context
+ key: extension
+ - type: metric
+ index: 1
+ source: attributes
+ key: someEventContextAttr
+```
+
+### Debugging and Testing
+
+In pre-production environments, you may wish to set additional configurations
+to turn off reporting to Analytics and/or print debug statements to the
+console. You can do so like this:
+
+```yaml
+app:
+ analytics:
+ ga:
+ testMode: true # Prevents data being sent to GA
+ debug: true # Logs analytics event to the web console
+```
+
+You might commonly set the above in an `app-config.local.yaml` file, which is
+normally `gitignore`'d but loaded and merged in when Backstage is bootstrapped.
+
+## Development
+
+If you would like to contribute improvements to this plugin, the easiest way to
+make and test changes is to do the following:
+
+1. Clone the main Backstage monorepo `git clone git@github.com:backstage/backstage.git`
+2. Install all dependencies `yarn install`
+3. If one does not exist, create an `app-config.local.yaml` file in the root of
+ the monorepo and add config for this plugin (see below)
+4. Enter this plugin's working directory: `cd plugins/analytics-provider-ga`
+5. Start the plugin in isolation: `yarn start`
+6. Navigate to the playground page at `http://localhost:3000/ga`
+7. Open the web console to see events fire when you navigate or when you
+ interact with instrumented components.
+
+Code for the isolated version of the plugin can be found inside the [/dev](./dev)
+directory. Changes to the plugin are hot-reloaded.
+
+#### Recommended Dev Config
+
+Paste this into your `app-config.local.yaml` while developing this plugin:
+
+```yaml
+app:
+ analytics:
+ ga:
+ trackingId: UA-0000000-0
+ debug: true
+ testMode: true
+ customDimensionsMetrics:
+ - type: dimension
+ index: 1
+ source: context
+ key: pluginId
+```
+
+[what-is-a-custom-dimension]: https://support.google.com/analytics/answer/2709828
+[configure-custom-dimension]: https://support.google.com/analytics/answer/2709828#configuration
diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md
new file mode 100644
index 0000000000..7ca3b1646f
--- /dev/null
+++ b/plugins/analytics-module-ga/api-report.md
@@ -0,0 +1,31 @@
+## API Report File for "@backstage/plugin-analytics-module-ga"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { AnalyticsApi } from '@backstage/core-plugin-api';
+import { AnalyticsEvent } from '@backstage/core-plugin-api';
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { Config } from '@backstage/config';
+
+// Warning: (ae-missing-release-tag) "analyticsModuleGA" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const analyticsModuleGA: BackstagePlugin<{}, {}>;
+
+// Warning: (ae-missing-release-tag) "GoogleAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export class GoogleAnalytics implements AnalyticsApi {
+ captureEvent({
+ context,
+ action,
+ subject,
+ value,
+ attributes,
+ }: AnalyticsEvent): void;
+ static fromConfig(config: Config): GoogleAnalytics;
+}
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/analytics-module-ga/config.d.ts b/plugins/analytics-module-ga/config.d.ts
new file mode 100644
index 0000000000..4ccd66e1dd
--- /dev/null
+++ b/plugins/analytics-module-ga/config.d.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020 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 interface Config {
+ app: {
+ // TODO: Only marked as optional because backstage-cli config:check in the
+ // context of the monorepo is too strict. Ideally, this would be marked as
+ // required.
+ analytics?: {
+ ga: {
+ /**
+ * Google Analytics tracking ID, e.g. UA-000000-0
+ * @visibility frontend
+ */
+ trackingId: string;
+
+ /**
+ * Whether or not to log analytics debug statements to the console.
+ * Defaults to false.
+ *
+ * @visibility frontend
+ */
+ debug?: boolean;
+
+ /**
+ * Prevents events from actually being sent when set to true. Defaults
+ * to false.
+ *
+ * @visibility frontend
+ */
+ testMode?: boolean;
+
+ /**
+ * Configuration informing how Analytics Context and Event Attributes
+ * metadata will be captured in Google Analytics.
+ */
+ customDimensionsMetrics?: Array<{
+ /**
+ * Specifies whether the corresponding metadata should be collected
+ * as a Google Analytics custom dimension or custom metric.
+ *
+ * @visibility frontend
+ */
+ type: 'dimension' | 'metric';
+
+ /**
+ * The index of the Google Analytics custom dimension or metric that
+ * the metadata should be written to.
+ *
+ * @visibility frontend
+ */
+ index: number;
+
+ /**
+ * Specifies whether the desired value lives as an attribute on the
+ * Analytics Context or the Event's Attributes.
+ *
+ * @visibility frontend
+ */
+ source: 'context' | 'attributes';
+
+ /**
+ * The property of the context or attributes that should be captured.
+ * e.g. to capture the Plugin ID associated with an event, the source
+ * should be set to "context" and the key should be set to pluginId.
+ *
+ * @visibility frontend
+ */
+ key: string;
+ }>;
+ };
+ };
+ };
+}
diff --git a/plugins/analytics-module-ga/dev/Playground.tsx b/plugins/analytics-module-ga/dev/Playground.tsx
new file mode 100644
index 0000000000..cf21aadacc
--- /dev/null
+++ b/plugins/analytics-module-ga/dev/Playground.tsx
@@ -0,0 +1,26 @@
+/*
+ * 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 { Link } from '@backstage/core-components';
+
+export const Playground = () => {
+ return (
+ <>
+ Click Here
+ >
+ );
+};
diff --git a/plugins/analytics-module-ga/dev/index.tsx b/plugins/analytics-module-ga/dev/index.tsx
new file mode 100644
index 0000000000..74b3439cff
--- /dev/null
+++ b/plugins/analytics-module-ga/dev/index.tsx
@@ -0,0 +1,28 @@
+/*
+ * 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 { createDevApp } from '@backstage/dev-utils';
+import { analyticsModuleGA } from '../src/plugin';
+import { Playground } from './Playground';
+
+createDevApp()
+ .registerPlugin(analyticsModuleGA)
+ .addPage({
+ path: '/ga',
+ title: 'GA Playground',
+ element: ,
+ })
+ .render();
diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json
new file mode 100644
index 0000000000..787c23c301
--- /dev/null
+++ b/plugins/analytics-module-ga/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@backstage/plugin-analytics-module-ga",
+ "version": "0.1.1",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "private": false,
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "scripts": {
+ "build": "backstage-cli plugin:build",
+ "start": "backstage-cli plugin:serve",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "diff": "backstage-cli plugin:diff",
+ "prepack": "backstage-cli prepack",
+ "postpack": "backstage-cli postpack",
+ "clean": "backstage-cli clean"
+ },
+ "dependencies": {
+ "@backstage/config": "^0.1.5",
+ "@backstage/core-components": "^0.6.0",
+ "@backstage/core-plugin-api": "^0.1.9",
+ "@backstage/theme": "^0.2.10",
+ "@material-ui/core": "^4.12.2",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "4.0.0-alpha.57",
+ "react": "^16.13.1",
+ "react-dom": "^16.13.1",
+ "react-ga": "^3.3.0",
+ "react-use": "^17.2.4"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.7.14",
+ "@backstage/core-app-api": "^0.1.15",
+ "@backstage/dev-utils": "^0.2.11",
+ "@backstage/test-utils": "^0.1.17",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^11.2.5",
+ "@testing-library/user-event": "^13.1.8",
+ "@types/jest": "^26.0.7",
+ "@types/node": "^14.14.32",
+ "cross-fetch": "^3.0.6",
+ "msw": "^0.29.0"
+ },
+ "files": [
+ "dist",
+ "config.d.ts"
+ ],
+ "configSchema": "config.d.ts"
+}
diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts
new file mode 100644
index 0000000000..cbcf95cb55
--- /dev/null
+++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts
@@ -0,0 +1,211 @@
+/*
+ * 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 { ConfigReader } from '@backstage/config';
+import ReactGA from 'react-ga';
+import { GoogleAnalytics } from './GoogleAnalytics';
+
+describe('GoogleAnalytics', () => {
+ const trackingId = 'UA-000000-0';
+ const basicValidConfig = new ConfigReader({
+ app: { analytics: { ga: { trackingId, testMode: true } } },
+ });
+
+ beforeEach(() => {
+ ReactGA.testModeAPI.resetCalls();
+ });
+
+ describe('fromConfig', () => {
+ it('throws when missing trackingId', () => {
+ const config = new ConfigReader({ app: { analytics: { ga: {} } } });
+ expect(() => GoogleAnalytics.fromConfig(config)).toThrowError(
+ /Missing required config value/,
+ );
+ });
+
+ it('returns implementation', () => {
+ const api = GoogleAnalytics.fromConfig(basicValidConfig);
+ expect(api.captureEvent).toBeDefined();
+
+ // Initializes GA with tracking ID.
+ expect(ReactGA.testModeAPI.calls[0]).toEqual([
+ 'create',
+ trackingId,
+ 'auto',
+ ]);
+ });
+ });
+
+ describe('integration', () => {
+ const context = {
+ extension: 'App',
+ pluginId: 'some-plugin',
+ routeRef: 'unknown',
+ releaseNum: 1337,
+ };
+ const advancedConfig = new ConfigReader({
+ app: {
+ analytics: {
+ ga: {
+ trackingId,
+ testMode: true,
+ customDimensionsMetrics: [
+ {
+ type: 'dimension',
+ index: 1,
+ source: 'context',
+ key: 'pluginId',
+ },
+ {
+ type: 'dimension',
+ index: 2,
+ source: 'attributes',
+ key: 'extraDimension',
+ },
+ {
+ type: 'metric',
+ index: 1,
+ source: 'context',
+ key: 'releaseNum',
+ },
+ {
+ type: 'metric',
+ index: 2,
+ source: 'attributes',
+ key: 'extraMetric',
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ it('tracks basic pageview', () => {
+ const api = GoogleAnalytics.fromConfig(basicValidConfig);
+ api.captureEvent({
+ action: 'navigate',
+ subject: '/',
+ context,
+ });
+
+ const [command, data] = ReactGA.testModeAPI.calls[1];
+ expect(command).toBe('send');
+ expect(data).toMatchObject({
+ hitType: 'pageview',
+ page: '/',
+ });
+ });
+
+ it('tracks basic event', () => {
+ const api = GoogleAnalytics.fromConfig(basicValidConfig);
+
+ const expectedAction = 'click';
+ const expectedLabel = 'on something';
+ const expectedValue = 42;
+ api.captureEvent({
+ action: expectedAction,
+ subject: expectedLabel,
+ value: expectedValue,
+ context,
+ });
+
+ const [command, data] = ReactGA.testModeAPI.calls[1];
+ expect(command).toBe('send');
+ expect(data).toMatchObject({
+ hitType: 'event',
+ eventCategory: context.extension,
+ eventAction: expectedAction,
+ eventLabel: expectedLabel,
+ eventValue: expectedValue,
+ });
+ });
+
+ it('captures configured custom dimensions/metrics on pageviews', () => {
+ const api = GoogleAnalytics.fromConfig(advancedConfig);
+ api.captureEvent({
+ action: 'navigate',
+ subject: '/a-page',
+ context,
+ });
+
+ // Expect a set command first.
+ const [setCommand, setData] = ReactGA.testModeAPI.calls[1];
+ expect(setCommand).toBe('set');
+ expect(setData).toMatchObject({
+ dimension1: context.pluginId,
+ metric1: context.releaseNum,
+ });
+
+ // Followed by a send command.
+ const [sendCommand, sendData] = ReactGA.testModeAPI.calls[2];
+ expect(sendCommand).toBe('send');
+ expect(sendData).toMatchObject({
+ hitType: 'pageview',
+ page: '/a-page',
+ });
+ });
+
+ it('captures configured custom dimensions/metrics on events', () => {
+ const api = GoogleAnalytics.fromConfig(advancedConfig);
+
+ const expectedAction = 'search';
+ const expectedLabel = 'some query';
+ const expectedValue = 5;
+ api.captureEvent({
+ action: expectedAction,
+ subject: expectedLabel,
+ value: expectedValue,
+ attributes: {
+ extraDimension: false,
+ extraMetric: 0,
+ },
+ context,
+ });
+
+ const [command, data] = ReactGA.testModeAPI.calls[1];
+ expect(command).toBe('send');
+ expect(data).toMatchObject({
+ hitType: 'event',
+ eventCategory: context.extension,
+ eventAction: expectedAction,
+ eventLabel: expectedLabel,
+ eventValue: expectedValue,
+ dimension1: context.pluginId,
+ metric1: context.releaseNum,
+ dimension2: false,
+ metric2: 0,
+ });
+ });
+
+ it('does not pass non-numeric data on metrics', () => {
+ const api = GoogleAnalytics.fromConfig(advancedConfig);
+
+ api.captureEvent({
+ action: 'verb',
+ subject: 'noun',
+ attributes: {
+ extraMetric: 'not a number',
+ },
+ context,
+ });
+
+ const [, data] = ReactGA.testModeAPI.calls[1];
+ expect(data).not.toMatchObject({
+ metric2: 'not a number',
+ });
+ });
+ });
+});
diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts
new file mode 100644
index 0000000000..1e6216936c
--- /dev/null
+++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts
@@ -0,0 +1,154 @@
+/*
+ * 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 ReactGA from 'react-ga';
+import {
+ AnalyticsApi,
+ AnalyticsContextValue,
+ AnalyticsEventAttributes,
+ AnalyticsEvent,
+} from '@backstage/core-plugin-api';
+import { Config } from '@backstage/config';
+
+type CustomDimensionOrMetricConfig = {
+ type: 'dimension' | 'metric';
+ index: number;
+ source: 'context' | 'attributes';
+ key: string;
+};
+
+/**
+ * Google Analytics API provider for the Backstage Analytics API.
+ */
+export class GoogleAnalytics implements AnalyticsApi {
+ private readonly cdmConfig: CustomDimensionOrMetricConfig[];
+
+ /**
+ * Instantiate the implementation and initialize ReactGA.
+ */
+ private constructor({
+ cdmConfig,
+ trackingId,
+ testMode,
+ debug,
+ }: {
+ cdmConfig: CustomDimensionOrMetricConfig[];
+ trackingId: string;
+ testMode: boolean;
+ debug: boolean;
+ }) {
+ this.cdmConfig = cdmConfig;
+
+ // Initialize Google Analytics.
+ ReactGA.initialize(trackingId, { testMode, debug, titleCase: false });
+ }
+
+ /**
+ * Instantiate a fully configured GA Analytics API implementation.
+ */
+ static fromConfig(config: Config) {
+ // Get all necessary configuration.
+ const trackingId = config.getString('app.analytics.ga.trackingId');
+ const debug = config.getOptionalBoolean('app.analytics.ga.debug') ?? false;
+ const testMode =
+ config.getOptionalBoolean('app.analytics.ga.testMode') ?? false;
+ const cdmConfig =
+ config
+ .getOptionalConfigArray('app.analytics.ga.customDimensionsMetrics')
+ ?.map(c => {
+ return {
+ type: c.getString('type') as CustomDimensionOrMetricConfig['type'],
+ index: c.getNumber('index'),
+ source: c.getString(
+ 'source',
+ ) as CustomDimensionOrMetricConfig['source'],
+ key: c.getString('key'),
+ };
+ }) ?? [];
+
+ // Return an implementation instance.
+ return new GoogleAnalytics({
+ trackingId,
+ cdmConfig,
+ testMode,
+ debug,
+ });
+ }
+
+ /**
+ * Primary event capture implementation. Handles core navigate event as a
+ * pageview and the rest as custom events. All custom dimensions/metrics are
+ * applied as they should be (set on pageview, merged object on events).
+ */
+ captureEvent({
+ context,
+ action,
+ subject,
+ value,
+ attributes,
+ }: AnalyticsEvent) {
+ const customMetadata = this.getCustomDimensionMetrics(context, attributes);
+
+ if (action === 'navigate' && context.extension === 'App') {
+ // Set any/all custom dimensions.
+ if (Object.keys(customMetadata).length) {
+ ReactGA.set(customMetadata);
+ }
+
+ ReactGA.pageview(subject);
+ return;
+ }
+
+ ReactGA.event({
+ category: context.extension || 'App',
+ action,
+ label: subject,
+ value,
+ ...customMetadata,
+ });
+ }
+
+ /**
+ * Returns an object of dimensions/metrics given an Analytics Context and an
+ * Event Attributes, e.g. { dimension1: "some value", metric8: 42 }
+ */
+ private getCustomDimensionMetrics(
+ context: AnalyticsContextValue,
+ attributes: AnalyticsEventAttributes = {},
+ ) {
+ const customDimensionsMetrics: { [x: string]: string | number | boolean } =
+ {};
+
+ this.cdmConfig.forEach(config => {
+ const value =
+ config.source === 'context'
+ ? context[config.key]
+ : attributes[config.key];
+
+ // Never pass a non-numeric value on a metric.
+ if (config.type === 'metric' && typeof value !== 'number') {
+ return;
+ }
+
+ // Only set defined values.
+ if (value !== undefined) {
+ customDimensionsMetrics[`${config.type}${config.index}`] = value;
+ }
+ });
+
+ return customDimensionsMetrics;
+ }
+}
diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/index.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/index.ts
new file mode 100644
index 0000000000..a8d11e4c6c
--- /dev/null
+++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 { GoogleAnalytics } from './GoogleAnalytics';
diff --git a/plugins/analytics-module-ga/src/index.ts b/plugins/analytics-module-ga/src/index.ts
new file mode 100644
index 0000000000..17a3213c6d
--- /dev/null
+++ b/plugins/analytics-module-ga/src/index.ts
@@ -0,0 +1,18 @@
+/*
+ * 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 { analyticsModuleGA } from './plugin';
+export { GoogleAnalytics } from './apis/implementations/AnalyticsApi';
diff --git a/plugins/analytics-module-ga/src/plugin.test.ts b/plugins/analytics-module-ga/src/plugin.test.ts
new file mode 100644
index 0000000000..394e5fc070
--- /dev/null
+++ b/plugins/analytics-module-ga/src/plugin.test.ts
@@ -0,0 +1,22 @@
+/*
+ * 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 { analyticsModuleGA } from './plugin';
+
+describe('google-analytics', () => {
+ it('should export plugin', () => {
+ expect(analyticsModuleGA).toBeDefined();
+ });
+});
diff --git a/plugins/analytics-module-ga/src/plugin.ts b/plugins/analytics-module-ga/src/plugin.ts
new file mode 100644
index 0000000000..d63ae6616f
--- /dev/null
+++ b/plugins/analytics-module-ga/src/plugin.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 { createPlugin } from '@backstage/core-plugin-api';
+
+export const analyticsModuleGA = createPlugin({
+ id: 'analytics-provider-ga',
+});
diff --git a/plugins/analytics-module-ga/src/setupTests.ts b/plugins/analytics-module-ga/src/setupTests.ts
new file mode 100644
index 0000000000..fc6dbd98f8
--- /dev/null
+++ b/plugins/analytics-module-ga/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 '@testing-library/jest-dom';
+import 'cross-fetch/polyfill';
diff --git a/plugins/search/src/components/SearchPage/SearchPage.test.tsx b/plugins/search/src/components/SearchPage/SearchPage.test.tsx
index 5ff61a3dbe..c9980b3bf7 100644
--- a/plugins/search/src/components/SearchPage/SearchPage.test.tsx
+++ b/plugins/search/src/components/SearchPage/SearchPage.test.tsx
@@ -76,7 +76,7 @@ describe('SearchPage', () => {
const expectedPageCursor = 'SOMEPAGE';
// e.g. ?query=petstore&pageCursor=SOMEPAGE&filters[lifecycle][]=experimental&filters[kind]=Component
- (useLocation as jest.Mock).mockReturnValueOnce({
+ (useLocation as jest.Mock).mockReturnValue({
search: `?query=${expectedTerm}&types[]=${expectedTypes[0]}&filters[${expectedFilterField}]=${expectedFilterValue}&pageCursor=${expectedPageCursor}`,
});
diff --git a/yarn.lock b/yarn.lock
index c961b6045b..ccae57b387 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -22941,6 +22941,11 @@ react-fast-compare@^3.0.1, react-fast-compare@^3.1.1, react-fast-compare@^3.2.0:
resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
+react-ga@^3.3.0:
+ version "3.3.0"
+ resolved "https://registry.npmjs.org/react-ga/-/react-ga-3.3.0.tgz#c91f407198adcb3b49e2bc5c12b3fe460039b3ca"
+ integrity sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ==
+
react-helmet-async@^1.0.7:
version "1.0.9"
resolved "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.0.9.tgz#5b9ed2059de6b4aab47f769532f9fbcbce16c5ca"