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"