From 45ae9d9cccd4aed5033fc7adbe335d80561f539b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 31 Jul 2021 20:33:58 +0200 Subject: [PATCH 01/25] Allow extensions to explicitly specify a component name. Signed-off-by: Eric Peterson --- .../core-plugin-api/src/extensions/extensions.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index 73b927afc8..db39d2088c 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -37,6 +37,7 @@ export function createRoutableExtension< >(options: { component: () => Promise; mountPoint: RouteRef; + name?: string; }): Extension { const { component, mountPoint } = options; return createReactExtension({ @@ -62,6 +63,7 @@ export function createRoutableExtension< }; const componentName = + options.name || (InnerComponent as { displayName?: string }).displayName || InnerComponent.name || 'LazyComponent'; @@ -84,6 +86,7 @@ export function createRoutableExtension< data: { 'core.mountPoint': mountPoint, }, + name: options.name, }); } @@ -91,10 +94,10 @@ export function createRoutableExtension< // ComponentType inserts children as an optional prop whether the inner component accepts it or not, // 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 }); + T extends (props: any) => JSX.Element | null +>(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 +108,7 @@ export function createReactExtension< >(options: { component: ComponentLoader; data?: Record; + name?: string; }): Extension { const { data = {} } = options; @@ -118,6 +122,7 @@ export function createReactExtension< Component = options.component.sync; } const componentName = + options.name || (Component as { displayName?: string }).displayName || Component.name || 'Component'; From 829bc698f4616e22dc4cb11cda155decf4c3adaa Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 30 Sep 2021 15:45:54 +0200 Subject: [PATCH 02/25] Add the Analytics API to core-plugin-api Signed-off-by: Eric Peterson --- .changeset/analytics-old-skipping-record.md | 23 ++++ packages/core-plugin-api/api-report.md | 121 ++++++++++++++++- .../src/analytics/AnalyticsDomain.test.tsx | 100 ++++++++++++++ .../src/analytics/AnalyticsDomain.tsx | 85 ++++++++++++ .../core-plugin-api/src/analytics/index.ts | 24 ++++ .../core-plugin-api/src/analytics/types.ts | 64 +++++++++ .../src/analytics/useAnalytics.test.tsx | 61 +++++++++ .../src/analytics/useAnalytics.tsx | 44 +++++++ .../src/apis/definitions/AnalyticsApi.ts | 123 ++++++++++++++++++ .../src/apis/definitions/index.ts | 1 + .../src/extensions/extensions.test.tsx | 29 +++++ .../src/extensions/extensions.tsx | 18 ++- packages/core-plugin-api/src/index.ts | 1 + 13 files changed, 689 insertions(+), 5 deletions(-) create mode 100644 .changeset/analytics-old-skipping-record.md create mode 100644 packages/core-plugin-api/src/analytics/AnalyticsDomain.test.tsx create mode 100644 packages/core-plugin-api/src/analytics/AnalyticsDomain.tsx create mode 100644 packages/core-plugin-api/src/analytics/index.ts create mode 100644 packages/core-plugin-api/src/analytics/types.ts create mode 100644 packages/core-plugin-api/src/analytics/useAnalytics.test.tsx create mode 100644 packages/core-plugin-api/src/analytics/useAnalytics.tsx create mode 100644 packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts diff --git a/.changeset/analytics-old-skipping-record.md b/.changeset/analytics-old-skipping-record.md new file mode 100644 index 0000000000..a1ee11af84 --- /dev/null +++ b/.changeset/analytics-old-skipping-record.md @@ -0,0 +1,23 @@ +--- +'@backstage/core-plugin-api': minor +--- + +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 a verb (the action performed), a noun (a unique + identifier of the object the action is being taken on), and optionally a + numeric metric/value. +- ``, a way to declaratively attach additional information + to any/all events captured in the underlying React tree. There is also a + `withAnalyticsDomain()` HOC utility. +- The `tracker.captureEvent()` method also accepts a `context` object for + optionally providing additional run-time information about an event. + +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/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index f92dbd44f0..7c833d2b66 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -34,6 +34,76 @@ 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 = { + getDecoratedTracker({ + domain, + }: { + domain: AnalyticsDomainValue; + }): AnalyticsTracker; +}; + +// 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) "AnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const AnalyticsDomain: ({ + attributes, + children, +}: { + attributes: AnalyticsDomainValue; + children: ReactNode; +}) => JSX.Element; + +// Warning: (ae-missing-release-tag) "AnalyticsDomainValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsDomainValue = Partial< + RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain +>; + +// 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 = { + verb: string; + noun: string; + value?: number; + context?: AnalyticsEventContext; +}; + +// Warning: (ae-missing-release-tag) "AnalyticsEventContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsEventContext = { + [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: ( + verb: string, + noun: string, + value?: number, + context?: AnalyticsEventContext, + ) => void; +}; + +// Warning: (ae-missing-release-tag) "AnyAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnyAnalyticsDomain = { + [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 +304,14 @@ export type BootErrorPageProps = { error: Error; }; +// Warning: (ae-missing-release-tag) "ComponentAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type ComponentAnalyticsDomain = { + pluginId: string; + componentName: 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 +351,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 +387,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 +395,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) // @@ -361,6 +444,13 @@ export type DiscoveryApi = { // @public (undocumented) export const discoveryApiRef: ApiRef; +// Warning: (ae-missing-release-tag) "DomainDecoratedAnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type DomainDecoratedAnalyticsEvent = AnalyticsEvent & { + domain: AnalyticsDomainValue; +}; + // Warning: (ae-missing-release-tag) "ElementCollection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -704,6 +794,15 @@ export type ProfileInfoApi = { getProfile(options?: AuthRequestOptions): Promise; }; +// Warning: (ae-missing-release-tag) "RoutableAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type RoutableAnalyticsDomain = { + pluginId: string; + routeRef: string; + componentName: string; +}; + // Warning: (ae-missing-release-tag) "RouteOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -832,6 +931,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) @@ -882,6 +986,17 @@ export function useRouteRefParams( _routeRef: RouteRef | SubRouteRef, ): Params; +// Warning: (ae-missing-release-tag) "withAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function withAnalyticsDomain

( + Component: React_2.ComponentType

, + domain: AnyAnalyticsDomain, +): { + (props: P): JSX.Element; + displayName: string; +}; + // Warning: (ae-missing-release-tag) "withApis" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -900,7 +1015,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/AnalyticsDomain.test.tsx b/packages/core-plugin-api/src/analytics/AnalyticsDomain.test.tsx new file mode 100644 index 0000000000..e83ae478e4 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/AnalyticsDomain.test.tsx @@ -0,0 +1,100 @@ +/* + * 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 { + AnalyticsDomain, + useAnalyticsDomain, + withAnalyticsDomain, +} from './AnalyticsDomain'; + +const DomainSpy = () => { + const domain = useAnalyticsDomain(); + return ( + <> +

{domain.routeRef}
+
{domain.pluginId}
+
{domain.componentName}
+
{domain.custom}
+ + ); +}; + +describe('AnalyticsDomain', () => { + describe('useAnalyticsDomain', () => { + it('returns default values', () => { + const { result } = renderHook(() => useAnalyticsDomain()); + expect(result.current).toEqual({ + componentName: 'App', + pluginId: 'root', + routeRef: 'unknown', + }); + }); + }); + + describe('AnalyticsDomain', () => { + it('uses default analytics domain', () => { + const result = render( + + + , + ); + + expect(result.getByTestId('component-name')).toHaveTextContent('App'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('root'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + + it('uses provided analytics domain', () => { + const result = render( + + + , + ); + + expect(result.getByTestId('component-name')).toHaveTextContent('App'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + + it('uses nested analytics domain', () => { + const result = render( + + + + + , + ); + + expect(result.getByTestId('component-name')).toHaveTextContent( + 'DomainSpy', + ); + expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + }); + + describe('withAnalyticsDomain', () => { + it('wraps component with analytics domain', () => { + const DomainSpyHOC = withAnalyticsDomain(DomainSpy, { custom: 'attr' }); + const result = render(); + expect(result.getByTestId('custom')).toHaveTextContent('attr'); + expect(DomainSpyHOC.displayName).toBe('WithAnalyticsDomain(DomainSpy)'); + }); + }); +}); diff --git a/packages/core-plugin-api/src/analytics/AnalyticsDomain.tsx b/packages/core-plugin-api/src/analytics/AnalyticsDomain.tsx new file mode 100644 index 0000000000..be1b90290c --- /dev/null +++ b/packages/core-plugin-api/src/analytics/AnalyticsDomain.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 { AnalyticsDomainValue, AnyAnalyticsDomain } from './types'; + +export const AnalyticsDomainContext = createContext({ + routeRef: 'unknown', + pluginId: 'root', + componentName: 'App', +}); + +/** + * A "private" (to this package) hook that enables context inheritance and a + * way to read Analytics Domain values at event capture-time. + * @private + */ +export const useAnalyticsDomain = () => { + return useContext(AnalyticsDomainContext); +}; + +/** + * Provides components in the child react tree an Analytics Domain, ensuring + * all analytics events captured within the domain have relevant contextual + * attributes. + * + * Analytics domains are additive, meaning the domain ultimately emitted with + * an event is the combination of all domains in the parent tree. + */ +export const AnalyticsDomain = ({ + attributes, + children, +}: { + attributes: AnalyticsDomainValue; + children: ReactNode; +}) => { + const parentValues = useAnalyticsDomain(); + const combinedValue = { + ...parentValues, + ...attributes, + }; + + return ( + + {children} + + ); +}; + +/** + * Returns an HOC wrapping the provided component in an Analytics Domain with + * the given values. + * + * @param Component - Component to be wrapped with analytics domain attributes. + * @param domain - Analytics domain key/value pairs. + */ +export function withAnalyticsDomain

( + Component: React.ComponentType

, + domain: AnyAnalyticsDomain, +) { + const ComponentWithAnalyticsDomain = (props: P) => { + return ( + + + + ); + }; + ComponentWithAnalyticsDomain.displayName = `WithAnalyticsDomain(${ + Component.displayName || Component.name || 'Component' + })`; + return ComponentWithAnalyticsDomain; +} 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..308069f9a8 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { AnalyticsDomain, withAnalyticsDomain } from './AnalyticsDomain'; +export type { + AnalyticsDomainValue, + AnyAnalyticsDomain, + ComponentAnalyticsDomain, + RoutableAnalyticsDomain, +} 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..acbda41da9 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/types.ts @@ -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. + */ + +/** + * Analytics domain covering routable extensions. + */ +export type RoutableAnalyticsDomain = { + /** + * The plugin that exposed the route. + */ + pluginId: string; + + /** + * The ID of the route ref associated with the route. + */ + routeRef: string; + + /** + * The name of the component used to render the route. + */ + componentName: string; +}; + +/** + * Analytics domain covering component extensions. + */ +export type ComponentAnalyticsDomain = { + /** + * The plugin that exposed the component. + */ + pluginId: string; + + /** + * The name of the component. + */ + componentName: string; +}; + +/** + * Allow arbitrary scalar values as domain attributes too. + */ +export type AnyAnalyticsDomain = { + [param in string]: string | boolean | number | undefined; +}; + +/** + * Common analytics domain attributes. + */ +export type AnalyticsDomainValue = Partial< + RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain +>; 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..33e71aa2e4 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook } from '@testing-library/react-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 expectedFunction = 'capture function'; + const getDecoratedTracker = jest.fn().mockReturnValue({ + captureEvent: expectedFunction, + }); + + // Simulate useApi returning a valid tracker. + mocked(useApi).mockReturnValue({ getDecoratedTracker }); + + // The getDecoratedTracker method of the underlying implementation should + // have been called with the domain provided. + const { result } = renderHook(() => useAnalytics()); + expect(getDecoratedTracker).toHaveBeenCalledWith({ + domain: { + componentName: 'App', + pluginId: 'root', + routeRef: 'unknown', + }, + }); + + // And the returned tracker's captureEvent should have come from the API + // implementation. + expect(result.current.captureEvent).toBe(expectedFunction); + }); +}); 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..e3dc851321 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useAnalyticsDomain } from './AnalyticsDomain'; +import { + analyticsApiRef, + AnalyticsTracker, +} from '../apis/definitions/AnalyticsApi'; +import { useApi } from '../apis'; + +function useTracker(): AnalyticsTracker { + const analyticsApi = useApi(analyticsApiRef); + const domain = useAnalyticsDomain(); + return analyticsApi.getDecoratedTracker({ domain }); +} + +/** + * Get a pre-configured analytics tracker. + */ +export function useAnalytics(): AnalyticsTracker { + // Return a no-op tracker if no implementation for the Analytics API is + // available. Having no default Analytics API implementation enables simple + // provider installation via plugin instantiation. + try { + return useTracker(); + } catch { + return { + captureEvent: () => {}, + }; + } +} 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..0e5f87b038 --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -0,0 +1,123 @@ +/* + * 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 { AnalyticsDomainValue } 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. + * + * Note that attributes about the Backstage user or about the plugin tracking + * the event are inferred and captured separately on the Analytics Domain and + * do not need to be passed on the event itself. + */ +export type AnalyticsEvent = { + /** + * A string that identifies the event being tracked by the type of action the + * event represents. Examples include: + * + * - view + * - click + * - filter + * - search + * - hover + * - scroll + */ + verb: string; + + /** + * A string that uniquely identifies the object that the verb or 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 + */ + noun: 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 + */ + value?: number; + + /** + * Optional context with any additional dimensions or metrics that could be + * forwarded on to analytics systems. + */ + context?: AnalyticsEventContext; +}; + +/** + * An analytics event combined with domain attributes. + */ +export type DomainDecoratedAnalyticsEvent = AnalyticsEvent & { + /** + * Domain metadata relating to where the event was captured and by whom. This + * could include information about the route, plugin, or component in which + * an event was captured. + */ + domain: AnalyticsDomainValue; +}; + +/** + * A structure allowing other arbitrary metadata to be provided by analytics + * event emitters. + */ +export type AnalyticsEventContext = { + [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: ( + verb: string, + noun: string, + value?: number, + context?: AnalyticsEventContext, + ) => 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 = { + /** + * Retrieves a tracker decorated with a given analytics domain. + */ + getDecoratedTracker({ + domain, + }: { + domain: AnalyticsDomainValue; + }): AnalyticsTracker; +}; + +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..422b346a2c 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 { useAnalyticsDomain } from '../analytics/AnalyticsDomain'; 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 domain', async () => { + const DomainSpyExtension = plugin.provide( + createReactExtension({ + name: 'DomainSpy', + component: { + sync: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const domain = useAnalyticsDomain(); + return ( + <> +

{domain.pluginId}
+
{domain.routeRef}
+
{domain.componentName}
+ + ); + }, + }, + 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('component-name')).toHaveTextContent('DomainSpy'); + }); }); diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index db39d2088c..444ca6783c 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 { AnalyticsDomain } from '../analytics/AnalyticsDomain'; import { useApp } from '../app'; import { RouteRef, useRouteRef } from '../routing'; import { attachComponentData } from './componentData'; @@ -94,7 +95,7 @@ export function createRoutableExtension< // ComponentType inserts children as an optional prop whether the inner component accepts it or not, // making it impossible to make the usage of children type safe. export function createComponentExtension< - T extends (props: any) => JSX.Element | null + T extends (props: any) => JSX.Element | null, >(options: { component: ComponentLoader; name?: string }): Extension { const { component, name } = options; return createReactExtension({ component, name }); @@ -136,7 +137,20 @@ export function createReactExtension< 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'; From e749a38e891340a592e94b50080274f27a9dd379 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 1 Aug 2021 15:58:44 +0200 Subject: [PATCH 03/25] Add MockAnalyticsApi to test-utils Signed-off-by: Eric Peterson --- .changeset/analytics-get-along-together.md | 6 +++ packages/test-utils/api-report.md | 18 +++++++ .../AnalyticsApi/MockAnalyticsApi.test.ts | 53 +++++++++++++++++++ .../apis/AnalyticsApi/MockAnalyticsApi.ts | 48 +++++++++++++++++ .../src/testUtils/apis/AnalyticsApi/index.ts | 17 ++++++ .../test-utils/src/testUtils/apis/index.ts | 1 + 6 files changed, 143 insertions(+) create mode 100644 .changeset/analytics-get-along-together.md create mode 100644 packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts create mode 100644 packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts create mode 100644 packages/test-utils/src/testUtils/apis/AnalyticsApi/index.ts 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/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index 214c26c825..9c6bc4d999 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -3,7 +3,11 @@ > 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 { AnalyticsDomainValue } from '@backstage/core-plugin-api'; +import { AnalyticsTracker } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; +import { DomainDecoratedAnalyticsEvent } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; import { ErrorContext } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; @@ -15,6 +19,20 @@ 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) + getDecoratedTracker({ + domain, + }: { + domain: AnalyticsDomainValue; + }): AnalyticsTracker; + // (undocumented) + getEvents(): DomainDecoratedAnalyticsEvent[]; +} + // 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..4113940072 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts @@ -0,0 +1,53 @@ +/* + * 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 domain = { + pluginId: 'some-plugin', + }; + + it('should collect events', () => { + const api = new MockAnalyticsApi(); + const tracker = api.getDecoratedTracker({ domain }); + + tracker.captureEvent('verb-1', 'noun-1'); + tracker.captureEvent('verb-2', 'noun-2', 42); + tracker.captureEvent('verb-3', 'noun-3', 1337, { some: 'context' }); + + expect(api.getEvents()[0]).toMatchObject({ + noun: 'noun-1', + verb: 'verb-1', + domain, + }); + expect(api.getEvents()[1]).toMatchObject({ + noun: 'noun-2', + verb: 'verb-2', + value: 42, + domain, + }); + expect(api.getEvents()[2]).toMatchObject({ + noun: 'noun-3', + verb: 'verb-3', + value: 1337, + domain, + context: { + 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..7e8e19d438 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.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. + */ + +import { + AnalyticsApi, + AnalyticsDomainValue, + AnalyticsTracker, + DomainDecoratedAnalyticsEvent, +} from '@backstage/core-plugin-api'; + +export class MockAnalyticsApi implements AnalyticsApi { + private events: DomainDecoratedAnalyticsEvent[] = []; + + getDecoratedTracker({ + domain, + }: { + domain: AnalyticsDomainValue; + }): AnalyticsTracker { + return { + captureEvent: (verb, noun, value, context) => { + this.events.push({ + verb, + noun, + domain, + ...(value !== undefined ? { value } : {}), + ...(context !== undefined ? { context } : {}), + }); + }, + }; + } + + getEvents(): DomainDecoratedAnalyticsEvent[] { + 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'; From d9fd798cc827f867d30c353c5a4ccf1361b61659 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 1 Aug 2021 16:06:39 +0200 Subject: [PATCH 04/25] Instrument core-app-api to capture 'navigate' events on location change. Signed-off-by: Eric Peterson --- .changeset/analytics-sings-stormy-weather.md | 24 ++++ packages/core-app-api/config.d.ts | 12 ++ packages/core-app-api/src/app/App.test.tsx | 83 ++++++++++++- packages/core-app-api/src/app/App.tsx | 5 +- .../core-app-api/src/routing/RouteTracker.tsx | 114 ++++++++++++++++++ 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 .changeset/analytics-sings-stormy-weather.md create mode 100644 packages/core-app-api/src/routing/RouteTracker.tsx diff --git a/.changeset/analytics-sings-stormy-weather.md b/.changeset/analytics-sings-stormy-weather.md new file mode 100644 index 0000000000..2a0d83e8a4 --- /dev/null +++ b/.changeset/analytics-sings-stormy-weather.md @@ -0,0 +1,24 @@ +--- +'@backstage/core-app-api': patch +--- + +The Core App API now automatically instruments all route location changes using +the new Analytics API. Each location change triggers a `navigate` event, which +is an analogue of a "pageview" event in traditional web analytics systems. In +addition to the path, these events provide plugin-level metadata via the +analytics domain, which can be useful for analyzing plugin usage: + +```json +{ + "verb": "navigate", + "noun": "/the-path/navigated/to?with=params#and-hashes", + "domain": { + "componentName": "App", + "pluginId": "id-of-plugin-that-exported-the-route", + "routeRef": "associated-route-ref-id" + } +} +``` + +These events can be identified and handled by checking for the verb `navigate` +and the componentName `App`. diff --git a/packages/core-app-api/config.d.ts b/packages/core-app-api/config.d.ts index d88c818d11..a2fa3f1989 100644 --- a/packages/core-app-api/config.d.ts +++ b/packages/core-app-api/config.d.ts @@ -65,6 +65,18 @@ export interface Config { }>; }>; }; + + /** + * Information about how analytics events should be collected in this + * Backstage Instance. + */ + analytics?: { + /** + * The provider used to collect instrumented events. Further + * configuration values depend on the provider specified here. + */ + provider: string; + }; }; /** diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/App.test.tsx index 6fc1d96fd8..93b711036b 100644 --- a/packages/core-app-api/src/app/App.test.tsx +++ b/packages/core-app-api/src/app/App.test.tsx @@ -15,11 +15,15 @@ */ import { LocalStorageFeatureFlags } from '../apis'; -import { renderWithEffects, withLogCollector } from '@backstage/test-utils'; +import { + MockAnalyticsApi, + renderWithEffects, + withLogCollector, +} from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { render, screen } from '@testing-library/react'; import React, { PropsWithChildren } from 'react'; -import { BrowserRouter, Routes } from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { defaultAppIcons } from './icons'; import { configApiRef, @@ -31,6 +35,7 @@ import { createRouteRef, createSubRouteRef, createRoutableExtension, + analyticsApiRef, } from '@backstage/core-plugin-api'; import { generateBoundRoutes, PrivateAppImpl } from './App'; import { AppThemeProvider } from './AppThemeProvider'; @@ -59,6 +64,7 @@ describe('generateBoundRoutes', () => { describe('Integration Test', () => { const plugin1RouteRef = createRouteRef({ id: 'ref-1' }); + const plugin1RouteRef2 = createRouteRef({ id: 'ref-1-2' }); const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] }); const subRouteRef1 = createSubRouteRef({ id: 'sub1', @@ -155,6 +161,16 @@ describe('Integration Test', () => { }), ); + const NavigateComponent = plugin1.provide( + createRoutableExtension({ + component: () => + Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { + return ; + }), + mountPoint: plugin1RouteRef2, + }), + ); + const components = { NotFoundErrorPage: () => null, BootErrorPage: () => null, @@ -322,6 +338,69 @@ describe('Integration Test', () => { }); }); + it('should track route changes via analytics api', async () => { + const mockAnalyticsApi = new MockAnalyticsApi(); + const apis = [createApiFactory(analyticsApiRef, mockAnalyticsApi)]; + const app = new PrivateAppImpl({ + apis, + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultAppIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + } /> + } /> + + + , + ); + + // Capture initial and subsequent navigation events with expected domain + // values. + const capturedEvents = mockAnalyticsApi.getEvents(); + expect(capturedEvents[0]).toMatchObject({ + verb: 'navigate', + noun: '/', + domain: { + componentName: 'App', + pluginId: 'blob', + routeRef: 'ref-1-2', + }, + }); + expect(capturedEvents[1]).toMatchObject({ + verb: 'navigate', + noun: '/foo', + domain: { + componentName: 'App', + pluginId: 'plugin2', + routeRef: 'ref-2', + }, + }); + expect(capturedEvents).toHaveLength(2); + }); + it('should throw some error when the route has duplicate params', () => { const app = new PrivateAppImpl({ apis: [], diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx index f5fec7e624..fb40a83dab 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/App.tsx @@ -23,7 +23,7 @@ import React, { useMemo, useState, } from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { createRoutesFromChildren, Route, Routes } from 'react-router-dom'; import { useAsync } from 'react-use'; import { ApiProvider, @@ -63,6 +63,7 @@ import { routePathCollector, } from '../routing/collectors'; import { RoutingProvider } from '../routing/RoutingProvider'; +import { RouteTracker } from '../routing/RouteTracker'; import { validateRoutes } from '../routing/validation'; import { AppContextProvider } from './AppContext'; import { AppIdentity } from './AppIdentity'; @@ -367,6 +368,7 @@ export class PrivateAppImpl implements BackstageApp { return ( + {children}} /> @@ -376,6 +378,7 @@ export class PrivateAppImpl implements BackstageApp { return ( + {children}} /> diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx new file mode 100644 index 0000000000..64e7643762 --- /dev/null +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, +} from 'react-router-dom'; +import { + BackstagePlugin, + useAnalytics, + getComponentData, + AnalyticsDomain, + RoutableAnalyticsDomain, +} from '@backstage/core-plugin-api'; + +type RouteObjects = ReturnType; + +/** + * Returns an extension domain given the current pathname and a RouteObject + * that defines all registered routes in react. + * + * If no exact match is found, path parts are stripped away, one-by-one, until + * a parent-level path matches a route. + */ +const getExtensionDomain = ( + pathname: string, + routes: RouteObjects, +): RoutableAnalyticsDomain | {} => { + const cleanPath = pathname.replace(/\/+$/, ''); + const matches = matchRoutes(routes, { pathname }); + const RouteElement = matches + ?.filter(match => { + const pathsMatch = match.pathname.replace(/\/+$/, '') === cleanPath; + const hasRoutableElement = !!(match.route.element as React.ReactElement) + ?.props?.element; + return pathsMatch && hasRoutableElement; + }) + .pop()?.route?.element; + const RoutableElement = (RouteElement as React.ReactElement)?.props?.element; + + if (RoutableElement) { + const plugin: BackstagePlugin | undefined = getComponentData( + RoutableElement, + 'core.plugin', + ); + const mountPoint: { id?: string } | undefined = getComponentData( + RoutableElement, + 'core.mountPoint', + ); + if (plugin && mountPoint) { + return { + pluginId: plugin.getId(), + componentName: 'App', + routeRef: mountPoint?.id || '', + }; + } + } + + // Try again, one path-level shallower. + const nextLevelPath = cleanPath.split('/').slice(0, -1).join('/'); + return nextLevelPath !== '' ? getExtensionDomain(nextLevelPath, routes) : {}; +}; + +/** + * Performs the actual event capture on render. + */ +const CaptureOnRender = ({ + pathname, + search, + hash, +}: { + pathname: string; + search: string; + hash: string; +}) => { + const analytics = useAnalytics(); + + useEffect(() => { + analytics.captureEvent('navigate', `${pathname}${search}${hash}`); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname, search, hash]); + + return null; +}; + +/** + * Logs a "navigate" event with appropriate plugin-level analytics domain + * attributes each time the user navigates to a page. + */ +export const RouteTracker = ({ objects }: { objects: RouteObjects }) => { + const { pathname, search, hash } = useLocation(); + const attributes = getExtensionDomain(pathname, objects); + + return ( + + + + ); +}; From f139fed1ac4106f41d8a6609f65fe54da61eefa7 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 16 Sep 2021 11:27:05 +0200 Subject: [PATCH 05/25] Instrument core-components Link to capture 'click' events on click Signed-off-by: Eric Peterson --- ...ytics-haunts-dismembered-constellations.md | 23 +++++++ .../src/components/Link/Link.test.tsx | 34 +++++++++- .../src/components/Link/Link.tsx | 66 +++++++++++++------ 3 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 .changeset/analytics-haunts-dismembered-constellations.md diff --git a/.changeset/analytics-haunts-dismembered-constellations.md b/.changeset/analytics-haunts-dismembered-constellations.md new file mode 100644 index 0000000000..cc685d68f0 --- /dev/null +++ b/.changeset/analytics-haunts-dismembered-constellations.md @@ -0,0 +1,23 @@ +--- +'@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 +location the user clicked to. In addition, these events inherit plugin-level +metadata, allowing clicks to be attributed to the plugin containing the link: + +```json +{ + "verb": "click", + "noun": "/value/of-the/to-prop/passed-to-the-link", + "domain": { + "componentName": "Link", + "pluginId": "plugin-in-which-link-was-clicked", + "routeRef": "any-associated-route-ref-id" + } +} +``` + +These events can be identified and handled by checking for the verb `click` +and the componentName `Link`. diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index 97a71c5760..0be4749f0a 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -16,10 +16,12 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { isExternalUri, Link } from './Link'; import { Route, Routes } from 'react-router'; import { act } from 'react-dom/test-utils'; +import { analyticsApiRef } from '../../../../core-plugin-api/src'; describe('', () => { it('navigates using react-router', async () => { @@ -40,6 +42,36 @@ describe('', () => { 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} + + , + ), + ); + + await act(async () => { + fireEvent.click(getByText(linkText)); + }); + + // Analytics event should have been fired. + expect(analyticsApi.getEvents()[0]).toMatchObject({ + verb: 'click', + noun: '/test', + domain: { componentName: 'Link' }, + }); + + // Custom onClick handler should have still been fired too. + expect(customOnClick).toHaveBeenCalled(); + }); + describe('isExternalUri', () => { it.each([ [true, 'http://'], diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index 0d301dccf9..b985fcb922 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -14,16 +14,19 @@ * limitations under the License. */ +import { useAnalytics, withAnalyticsDomain } from '@backstage/core-plugin-api'; import { Link as MaterialLink, LinkProps as MaterialLinkProps, } from '@material-ui/core'; -import React, { ElementType } from 'react'; +import React, { ElementType, MutableRefObject } from 'react'; import { Link as RouterLink, LinkProps as RouterLinkProps, } from 'react-router-dom'; +type OptionalRef = MutableRefObject | ((instance: any) => void) | null; + export const isExternalUri = (uri: string) => /^([a-z+.-]+):/.test(uri); export type LinkProps = MaterialLinkProps & @@ -36,28 +39,53 @@ declare function LinkType(props: LinkProps): JSX.Element; /** * Thin wrapper on top of material-ui's Link component * Makes the Link to utilise react-router + * 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((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 ActualLink = withAnalyticsDomain( + ({ inputRef, onClick, ...props }: LinkProps & { inputRef: OptionalRef }) => { + const analytics = useAnalytics(); + const to = String(props.to); + const external = isExternalUri(to); + const newWindow = external && !!/^https?:/.exec(to); + + const handleClick = (event: React.MouseEvent) => { + if (onClick !== undefined) { + onClick(event); + } + analytics.captureEvent('click', to); + }; + + return external ? ( + // External links + + ) : ( + // Interact with React Router for internal links + + ); + }, + { componentName: 'Link' }, +); + +export const WrappedLink = React.forwardRef((props, ref) => ( + +)); // TODO(Rugvip): We use this as a workaround to make the exported type be a // function, which makes our API reference docs much nicer. // The first type to be exported gets priority, but it will // be thrown away when compiling to JS. // @ts-ignore -export { LinkType as Link, ActualLink as Link }; +export { LinkType as Link, WrappedLink as Link }; From c582819a019a5fd1f3fd12a0ae96a9e0e78be923 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 1 Aug 2021 18:06:01 +0200 Subject: [PATCH 06/25] Add analytics-provider-ga plugin Signed-off-by: Eric Peterson --- .../analytics-glimmering-radio-station.md | 9 + .github/styles/vocab.txt | 1 + plugins/analytics-provider-ga/.eslintrc.js | 3 + plugins/analytics-provider-ga/README.md | 131 ++++++++++++ plugins/analytics-provider-ga/config.d.ts | 86 ++++++++ .../analytics-provider-ga/dev/Playground.tsx | 29 +++ .../analytics-provider-ga/dev/app-config.yaml | 3 + plugins/analytics-provider-ga/dev/index.tsx | 28 +++ plugins/analytics-provider-ga/package.json | 53 +++++ .../AnalyticsApi/GoogleAnalytics.test.ts | 191 ++++++++++++++++++ .../AnalyticsApi/GoogleAnalytics.ts | 175 ++++++++++++++++ .../implementations/AnalyticsApi/index.ts | 17 ++ plugins/analytics-provider-ga/src/index.ts | 16 ++ .../analytics-provider-ga/src/plugin.test.ts | 22 ++ plugins/analytics-provider-ga/src/plugin.ts | 33 +++ .../analytics-provider-ga/src/setupTests.ts | 17 ++ yarn.lock | 7 +- 17 files changed, 820 insertions(+), 1 deletion(-) create mode 100644 .changeset/analytics-glimmering-radio-station.md create mode 100644 plugins/analytics-provider-ga/.eslintrc.js create mode 100644 plugins/analytics-provider-ga/README.md create mode 100644 plugins/analytics-provider-ga/config.d.ts create mode 100644 plugins/analytics-provider-ga/dev/Playground.tsx create mode 100644 plugins/analytics-provider-ga/dev/app-config.yaml create mode 100644 plugins/analytics-provider-ga/dev/index.tsx create mode 100644 plugins/analytics-provider-ga/package.json create mode 100644 plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts create mode 100644 plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts create mode 100644 plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/index.ts create mode 100644 plugins/analytics-provider-ga/src/index.ts create mode 100644 plugins/analytics-provider-ga/src/plugin.test.ts create mode 100644 plugins/analytics-provider-ga/src/plugin.ts create mode 100644 plugins/analytics-provider-ga/src/setupTests.ts diff --git a/.changeset/analytics-glimmering-radio-station.md b/.changeset/analytics-glimmering-radio-station.md new file mode 100644 index 0000000000..fb0c404eee --- /dev/null +++ b/.changeset/analytics-glimmering-radio-station.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-analytics-provider-ga': patch +--- + +Initial Google Analytics API provider for the Backstage Analytics API, which: + +- Handles pageview and custom event instrumentation idiomatically. +- Enables custom dimension and metric collection via app config. +- Includes configurable debug/test mode for non-production environments. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index a38baab3dc..7508ab8033 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -88,6 +88,7 @@ firehydrant FireHydrant Firekube Fiverr +ga gitbeaker GitHub GitLab diff --git a/plugins/analytics-provider-ga/.eslintrc.js b/plugins/analytics-provider-ga/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/analytics-provider-ga/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/analytics-provider-ga/README.md b/plugins/analytics-provider-ga/README.md new file mode 100644 index 0000000000..e7a603a34a --- /dev/null +++ b/plugins/analytics-provider-ga/README.md @@ -0,0 +1,131 @@ +# Analytics Provider: 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 (and will never contain any) other functionality. + +## Installation + +1. Install this plugin in your Backstage instance: `yarn add @backstage/plugin-analytics-provider-ga` +2. Register the plugin with your App. In most instances of Backstage, this is + as simple as adding `export { analyticsProviderGA } from '@backstage/plugin-analytics-provider-ga';` + to your `packages/app/src/plugins.ts` file. +3. Configure the plugin (see below). + +## Configuration + +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: + provider: ga + ga: + trackingId: UA-0000000-0 +``` + +### Plugin Analytics + +In order to be able to analyze usage of your Backstage instance _by plugin_, we +strongly recommend configuring at least one 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: + provider: ga + ga: + trackingId: UA-0000000-0 + customDimensionsMetrics: + - type: dimension + index: 1 + source: domain + attribute: 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: domain + attribute: pluginId + - type: dimension + index: 2 + source: domain + attribute: routeRef + - type: metric + index: 1 + source: context + attribute: 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 +``` + +## 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 [/ga](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: + provider: ga + ga: + trackingId: UA-0000000-0 + debug: true + testMode: true + customDimensionsMetrics: + - type: dimension + index: 1 + source: domain + attribute: pluginId +``` + +[configure-custom-dimension]: https://support.google.com/analytics/answer/2709828?hl=en#configuration diff --git a/plugins/analytics-provider-ga/config.d.ts b/plugins/analytics-provider-ga/config.d.ts new file mode 100644 index 0000000000..0b08b75f1e --- /dev/null +++ b/plugins/analytics-provider-ga/config.d.ts @@ -0,0 +1,86 @@ +/* + * 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: { + analytics: { + provider: 'ga'; + 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 Domain and Event Context + * 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 Domain or the Event's Context. + * + * @visibility frontend + */ + source: 'domain' | 'context'; + + /** + * The attribute on the domain or context that should be captured. + * e.g. to capture the Plugin ID associated with an event, the source + * should be set to "domain" and the attribute should be set to + * pluginId. + * + * @visibility frontend + */ + attribute: string; + }>; + }; + }; + }; +} diff --git a/plugins/analytics-provider-ga/dev/Playground.tsx b/plugins/analytics-provider-ga/dev/Playground.tsx new file mode 100644 index 0000000000..d2817f5ab4 --- /dev/null +++ b/plugins/analytics-provider-ga/dev/Playground.tsx @@ -0,0 +1,29 @@ +/* + * 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 { withAnalyticsDomain } from '@backstage/core-plugin-api'; +import { Link } from '@backstage/core-components'; + +const DomainlessPlayground = () => { + return ( + <> + Click Here + + ); +}; + +export const Playground = withAnalyticsDomain(DomainlessPlayground, {}); diff --git a/plugins/analytics-provider-ga/dev/app-config.yaml b/plugins/analytics-provider-ga/dev/app-config.yaml new file mode 100644 index 0000000000..3d5b23a356 --- /dev/null +++ b/plugins/analytics-provider-ga/dev/app-config.yaml @@ -0,0 +1,3 @@ +app: + analytics: + provider: ga diff --git a/plugins/analytics-provider-ga/dev/index.tsx b/plugins/analytics-provider-ga/dev/index.tsx new file mode 100644 index 0000000000..4e4e9082bb --- /dev/null +++ b/plugins/analytics-provider-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 { analyticsProviderGA } from '../src/plugin'; +import { Playground } from './Playground'; + +createDevApp() + .registerPlugin(analyticsProviderGA) + .addPage({ + path: '/ga', + title: 'GA Playground', + element: , + }) + .render(); diff --git a/plugins/analytics-provider-ga/package.json b/plugins/analytics-provider-ga/package.json new file mode 100644 index 0000000000..af60d0cb5c --- /dev/null +++ b/plugins/analytics-provider-ga/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-analytics-provider-ga", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "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.1.6", + "@backstage/core-plugin-api": "^0.1.3", + "@backstage/theme": "^0.2.8", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-ga": "^3.3.0", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.7.4", + "@backstage/core-app-api": "^0.1.4", + "@backstage/dev-utils": "^0.2.2", + "@backstage/test-utils": "^0.1.14", + "@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-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts b/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts new file mode 100644 index 0000000000..220ff5261b --- /dev/null +++ b/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -0,0 +1,191 @@ +/* + * 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.getDecoratedTracker).toBeDefined(); + + // Initializes GA with tracking ID. + expect(ReactGA.testModeAPI.calls[0]).toEqual([ + 'create', + trackingId, + 'auto', + ]); + }); + }); + + describe('integration', () => { + const domain = { + componentName: 'App', + pluginId: 'some-plugin', + releaseNum: 1337, + }; + const advancedConfig = new ConfigReader({ + app: { + analytics: { + ga: { + trackingId, + testMode: true, + customDimensionsMetrics: [ + { + type: 'dimension', + index: 1, + source: 'domain', + attribute: 'pluginId', + }, + { + type: 'dimension', + index: 2, + source: 'context', + attribute: 'extraDimension', + }, + { + type: 'metric', + index: 1, + source: 'domain', + attribute: 'releaseNum', + }, + { + type: 'metric', + index: 2, + source: 'context', + attribute: 'extraMetric', + }, + ], + }, + }, + }, + }); + + it('tracks basic pageview', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + const tracker = api.getDecoratedTracker({ domain }); + tracker.captureEvent('navigate', '/'); + + 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 tracker = api.getDecoratedTracker({ domain }); + + const expectedAction = 'click'; + const expectedLabel = 'on something'; + const expectedValue = 42; + tracker.captureEvent(expectedAction, expectedLabel, expectedValue); + + const [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + eventCategory: domain.componentName, + eventAction: expectedAction, + eventLabel: expectedLabel, + eventValue: expectedValue, + }); + }); + + it('captures configured custom dimensions/metrics on pageviews', () => { + const api = GoogleAnalytics.fromConfig(advancedConfig); + const tracker = api.getDecoratedTracker({ domain }); + tracker.captureEvent('navigate', '/a-page'); + + // Expect a set command first. + const [setCommand, setData] = ReactGA.testModeAPI.calls[1]; + expect(setCommand).toBe('set'); + expect(setData).toMatchObject({ + dimension1: domain.pluginId, + metric1: domain.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 tracker = api.getDecoratedTracker({ domain }); + + const expectedAction = 'search'; + const expectedLabel = 'some query'; + const expectedValue = 5; + tracker.captureEvent(expectedAction, expectedLabel, expectedValue, { + extraDimension: false, + extraMetric: 0, + }); + + const [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + eventCategory: domain.componentName, + eventAction: expectedAction, + eventLabel: expectedLabel, + eventValue: expectedValue, + dimension1: domain.pluginId, + metric1: domain.releaseNum, + dimension2: false, + metric2: 0, + }); + }); + + it('does not pass non-numeric data on metrics', () => { + const api = GoogleAnalytics.fromConfig(advancedConfig); + const tracker = api.getDecoratedTracker({ domain }); + + tracker.captureEvent('verb', 'noun', undefined, { + extraMetric: 'not a number', + }); + + const [, data] = ReactGA.testModeAPI.calls[1]; + expect(data).not.toMatchObject({ + metric2: 'not a number', + }); + }); + }); +}); diff --git a/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts new file mode 100644 index 0000000000..d02924d138 --- /dev/null +++ b/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -0,0 +1,175 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ReactGA from 'react-ga'; +import { + AnalyticsApi, + AnalyticsTracker, + AnalyticsDomainValue, + AnalyticsEventContext, +} from '@backstage/core-plugin-api'; +import { Config } from '@backstage/config'; + +type CDM = { + type: 'dimension' | 'metric'; + index: number; + source: 'domain' | 'context'; + attribute: string; +}; + +/** + * Type guard for emptiness check in array filter. + */ +function notEmpty(value: T | undefined): value is T { + return value !== undefined; +} + +/** + * Google Analytics API provider for the Backstage Analytics API. + */ +export class GoogleAnalytics implements AnalyticsApi { + private readonly cdmConfig: CDM[]; + + /** + * Instantiate the implementation and initialize ReactGA. + */ + private constructor({ + cdmConfig, + trackingId, + testMode, + debug, + }: { + cdmConfig: CDM[]; + 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'); + const testMode = !!config.getOptionalBoolean('app.analytics.ga.testMode'); + const cdmConfig = + config + .getOptionalConfigArray('app.analytics.ga.customDimensionsMetrics') + ?.map(c => { + return { + type: c.getString('type') as CDM['type'], + index: c.getNumber('index'), + source: c.getString('source') as CDM['source'], + attribute: c.getString('attribute'), + }; + }) || []; + + // Return an implementation instance. + return new GoogleAnalytics({ + trackingId, + cdmConfig, + testMode, + debug, + }); + } + + /** + * Returns the Google Analytics tracker to API consumers. + */ + getDecoratedTracker({ + domain, + }: { + domain: AnalyticsDomainValue; + }): AnalyticsTracker { + return { + captureEvent: (...args) => this.captureEvent(domain, ...args), + }; + } + + /** + * 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). + */ + private captureEvent( + domain: AnalyticsDomainValue, + verb: string, + noun: string, + value?: number, + context?: AnalyticsEventContext, + ) { + const customMetadata = this.getCustomDimensionMetrics(domain, context); + + if (verb === 'navigate' && domain?.componentName === 'App') { + // Set any/all custom dimensions. + if (Object.keys(customMetadata).length) { + ReactGA.set(customMetadata); + } + + ReactGA.pageview(noun); + return; + } + + ReactGA.event({ + category: domain.componentName || 'App', + action: verb, + label: noun, + value, + ...customMetadata, + }); + } + + /** + * Returns an object of dimensions/metrics given an Analytics Domain and an + * Event Context, e.g. { dimension1: "some value", metric8: 42 } + */ + private getCustomDimensionMetrics( + domain: AnalyticsDomainValue, + context: AnalyticsEventContext = {}, + ) { + const dataArray = this.cdmConfig + .map(cdm => { + const value = + cdm.source === 'domain' + ? domain[cdm.attribute] + : context[cdm.attribute]; + + // Never pass a non-numeric value on a metric. + if (cdm.type === 'metric' && typeof value !== 'number') { + return undefined; + } + + return value !== undefined + ? { + [`${cdm.type}${cdm.index}`]: value, + } + : undefined; + }) + .filter(notEmpty); + + return dataArray.length + ? dataArray.reduce((result, cd) => { + return Object.assign(result, cd); + }) + : {}; + } +} diff --git a/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/index.ts b/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/index.ts new file mode 100644 index 0000000000..a8d11e4c6c --- /dev/null +++ b/plugins/analytics-provider-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-provider-ga/src/index.ts b/plugins/analytics-provider-ga/src/index.ts new file mode 100644 index 0000000000..8dbf2d6210 --- /dev/null +++ b/plugins/analytics-provider-ga/src/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { analyticsProviderGA } from './plugin'; diff --git a/plugins/analytics-provider-ga/src/plugin.test.ts b/plugins/analytics-provider-ga/src/plugin.test.ts new file mode 100644 index 0000000000..92d12ea6db --- /dev/null +++ b/plugins/analytics-provider-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 { analyticsProviderGA } from './plugin'; + +describe('google-analytics', () => { + it('should export plugin', () => { + expect(analyticsProviderGA).toBeDefined(); + }); +}); diff --git a/plugins/analytics-provider-ga/src/plugin.ts b/plugins/analytics-provider-ga/src/plugin.ts new file mode 100644 index 0000000000..e84dbbcd04 --- /dev/null +++ b/plugins/analytics-provider-ga/src/plugin.ts @@ -0,0 +1,33 @@ +/* + * 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 { + analyticsApiRef, + configApiRef, + createApiFactory, + createPlugin, +} from '@backstage/core-plugin-api'; +import { GoogleAnalytics } from './apis/implementations/AnalyticsApi'; + +export const analyticsProviderGA = createPlugin({ + id: 'analytics-provider-ga', + apis: [ + createApiFactory({ + api: analyticsApiRef, + deps: { config: configApiRef }, + factory: ({ config }) => GoogleAnalytics.fromConfig(config), + }), + ], +}); diff --git a/plugins/analytics-provider-ga/src/setupTests.ts b/plugins/analytics-provider-ga/src/setupTests.ts new file mode 100644 index 0000000000..fc6dbd98f8 --- /dev/null +++ b/plugins/analytics-provider-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/yarn.lock b/yarn.lock index 19869e641a..0a97f1435d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23031,11 +23031,16 @@ react-fast-compare@^2.0.4: resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -react-fast-compare@^3.0.1, react-fast-compare@^3.1.1, react-fast-compare@^3.2.0: +react-fast-compare@^3.0.1, react-fast-compare@^3.1.1: version "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" From edd5293d685293f1eec86964d1bd9813490857e5 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 5 Sep 2021 21:19:33 +0200 Subject: [PATCH 07/25] Review feedback. Signed-off-by: Eric Peterson --- .../analytics-glimmering-radio-station.md | 4 +- .../core-app-api/src/routing/RouteTracker.tsx | 67 ++++++++++--------- .../.eslintrc.js | 0 .../README.md | 7 +- plugins/analytics-module-ga/api-report.md | 14 ++++ .../config.d.ts | 5 +- .../dev/Playground.tsx | 0 .../dev/index.tsx | 4 +- .../package.json | 18 ++--- .../AnalyticsApi/GoogleAnalytics.test.ts | 0 .../AnalyticsApi/GoogleAnalytics.ts | 0 .../implementations/AnalyticsApi/index.ts | 0 .../src/index.ts | 2 +- .../src/plugin.test.ts | 4 +- .../src/plugin.ts | 2 +- .../src/setupTests.ts | 0 .../analytics-provider-ga/dev/app-config.yaml | 3 - .../components/SearchPage/SearchPage.test.tsx | 4 +- 18 files changed, 78 insertions(+), 56 deletions(-) rename plugins/{analytics-provider-ga => analytics-module-ga}/.eslintrc.js (100%) rename plugins/{analytics-provider-ga => analytics-module-ga}/README.md (93%) create mode 100644 plugins/analytics-module-ga/api-report.md rename plugins/{analytics-provider-ga => analytics-module-ga}/config.d.ts (92%) rename plugins/{analytics-provider-ga => analytics-module-ga}/dev/Playground.tsx (100%) rename plugins/{analytics-provider-ga => analytics-module-ga}/dev/index.tsx (90%) rename plugins/{analytics-provider-ga => analytics-module-ga}/package.json (76%) rename plugins/{analytics-provider-ga => analytics-module-ga}/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts (100%) rename plugins/{analytics-provider-ga => analytics-module-ga}/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts (100%) rename plugins/{analytics-provider-ga => analytics-module-ga}/src/apis/implementations/AnalyticsApi/index.ts (100%) rename plugins/{analytics-provider-ga => analytics-module-ga}/src/index.ts (92%) rename plugins/{analytics-provider-ga => analytics-module-ga}/src/plugin.test.ts (87%) rename plugins/{analytics-provider-ga => analytics-module-ga}/src/plugin.ts (95%) rename plugins/{analytics-provider-ga => analytics-module-ga}/src/setupTests.ts (100%) delete mode 100644 plugins/analytics-provider-ga/dev/app-config.yaml diff --git a/.changeset/analytics-glimmering-radio-station.md b/.changeset/analytics-glimmering-radio-station.md index fb0c404eee..2bc5aef678 100644 --- a/.changeset/analytics-glimmering-radio-station.md +++ b/.changeset/analytics-glimmering-radio-station.md @@ -1,8 +1,8 @@ --- -'@backstage/plugin-analytics-provider-ga': patch +'@backstage/plugin-analytics-module-ga': patch --- -Initial Google Analytics API provider for the Backstage Analytics API, which: +Initial Google Analytics implementation for the Backstage Analytics API, which: - Handles pageview and custom event instrumentation idiomatically. - Enables custom dimension and metric collection via app config. diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index 64e7643762..cce4692928 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -41,39 +41,46 @@ const getExtensionDomain = ( pathname: string, routes: RouteObjects, ): RoutableAnalyticsDomain | {} => { - const cleanPath = pathname.replace(/\/+$/, ''); - const matches = matchRoutes(routes, { pathname }); - const RouteElement = matches - ?.filter(match => { - const pathsMatch = match.pathname.replace(/\/+$/, '') === cleanPath; - const hasRoutableElement = !!(match.route.element as React.ReactElement) - ?.props?.element; - return pathsMatch && hasRoutableElement; - }) - .pop()?.route?.element; - const RoutableElement = (RouteElement as React.ReactElement)?.props?.element; + try { + const cleanPath = pathname.replace(/\/+$/, ''); + const matches = matchRoutes(routes, { pathname }); + const RouteElement = matches + ?.filter(match => { + const pathsMatch = match.pathname.replace(/\/+$/, '') === cleanPath; + const hasRoutableElement = !!(match.route.element as React.ReactElement) + ?.props?.element; + return pathsMatch && hasRoutableElement; + }) + .pop()?.route?.element; + const RoutableElement = (RouteElement as React.ReactElement)?.props + ?.element; - if (RoutableElement) { - const plugin: BackstagePlugin | undefined = getComponentData( - RoutableElement, - 'core.plugin', - ); - const mountPoint: { id?: string } | undefined = getComponentData( - RoutableElement, - 'core.mountPoint', - ); - if (plugin && mountPoint) { - return { - pluginId: plugin.getId(), - componentName: 'App', - routeRef: mountPoint?.id || '', - }; + if (RoutableElement) { + const plugin: BackstagePlugin | undefined = getComponentData( + RoutableElement, + 'core.plugin', + ); + const mountPoint: { id?: string } | undefined = getComponentData( + RoutableElement, + 'core.mountPoint', + ); + if (plugin && mountPoint) { + return { + pluginId: plugin.getId(), + componentName: 'App', + routeRef: mountPoint?.id || '', + }; + } } - } - // Try again, one path-level shallower. - const nextLevelPath = cleanPath.split('/').slice(0, -1).join('/'); - return nextLevelPath !== '' ? getExtensionDomain(nextLevelPath, routes) : {}; + // Try again, one path-level shallower. + const nextLevelPath = cleanPath.split('/').slice(0, -1).join('/'); + return nextLevelPath !== '' + ? getExtensionDomain(nextLevelPath, routes) + : {}; + } catch { + return {}; + } }; /** diff --git a/plugins/analytics-provider-ga/.eslintrc.js b/plugins/analytics-module-ga/.eslintrc.js similarity index 100% rename from plugins/analytics-provider-ga/.eslintrc.js rename to plugins/analytics-module-ga/.eslintrc.js diff --git a/plugins/analytics-provider-ga/README.md b/plugins/analytics-module-ga/README.md similarity index 93% rename from plugins/analytics-provider-ga/README.md rename to plugins/analytics-module-ga/README.md index e7a603a34a..09bbdeb88b 100644 --- a/plugins/analytics-provider-ga/README.md +++ b/plugins/analytics-module-ga/README.md @@ -1,4 +1,4 @@ -# Analytics Provider: Google Analytics +# 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 @@ -8,9 +8,10 @@ This plugin contains no (and will never contain any) other functionality. ## Installation -1. Install this plugin in your Backstage instance: `yarn add @backstage/plugin-analytics-provider-ga` +1. Install this plugin in your Backstage app: + `cd packages/app && yarn add @backstage/plugin-analytics-module-ga` 2. Register the plugin with your App. In most instances of Backstage, this is - as simple as adding `export { analyticsProviderGA } from '@backstage/plugin-analytics-provider-ga';` + as simple as adding `export { analyticsModuleGA } from '@backstage/plugin-analytics-module-ga';` to your `packages/app/src/plugins.ts` file. 3. Configure the plugin (see below). diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md new file mode 100644 index 0000000000..6208ebe795 --- /dev/null +++ b/plugins/analytics-module-ga/api-report.md @@ -0,0 +1,14 @@ +## 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 { BackstagePlugin } from '@backstage/core-plugin-api'; + +// 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<{}, {}>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/analytics-provider-ga/config.d.ts b/plugins/analytics-module-ga/config.d.ts similarity index 92% rename from plugins/analytics-provider-ga/config.d.ts rename to plugins/analytics-module-ga/config.d.ts index 0b08b75f1e..78772d932a 100644 --- a/plugins/analytics-provider-ga/config.d.ts +++ b/plugins/analytics-module-ga/config.d.ts @@ -16,7 +16,10 @@ export interface Config { app: { - analytics: { + // 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?: { provider: 'ga'; ga: { /** diff --git a/plugins/analytics-provider-ga/dev/Playground.tsx b/plugins/analytics-module-ga/dev/Playground.tsx similarity index 100% rename from plugins/analytics-provider-ga/dev/Playground.tsx rename to plugins/analytics-module-ga/dev/Playground.tsx diff --git a/plugins/analytics-provider-ga/dev/index.tsx b/plugins/analytics-module-ga/dev/index.tsx similarity index 90% rename from plugins/analytics-provider-ga/dev/index.tsx rename to plugins/analytics-module-ga/dev/index.tsx index 4e4e9082bb..74b3439cff 100644 --- a/plugins/analytics-provider-ga/dev/index.tsx +++ b/plugins/analytics-module-ga/dev/index.tsx @@ -15,11 +15,11 @@ */ import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { analyticsProviderGA } from '../src/plugin'; +import { analyticsModuleGA } from '../src/plugin'; import { Playground } from './Playground'; createDevApp() - .registerPlugin(analyticsProviderGA) + .registerPlugin(analyticsModuleGA) .addPage({ path: '/ga', title: 'GA Playground', diff --git a/plugins/analytics-provider-ga/package.json b/plugins/analytics-module-ga/package.json similarity index 76% rename from plugins/analytics-provider-ga/package.json rename to plugins/analytics-module-ga/package.json index af60d0cb5c..464151b614 100644 --- a/plugins/analytics-provider-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -1,5 +1,5 @@ { - "name": "@backstage/plugin-analytics-provider-ga", + "name": "@backstage/plugin-analytics-module-ga", "version": "0.1.1", "main": "src/index.ts", "types": "src/index.ts", @@ -21,10 +21,10 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.1.6", - "@backstage/core-plugin-api": "^0.1.3", - "@backstage/theme": "^0.2.8", - "@material-ui/core": "^4.11.0", + "@backstage/core-components": "^0.2.0", + "@backstage/core-plugin-api": "^0.1.4", + "@backstage/theme": "^0.2.9", + "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", @@ -33,10 +33,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.4", - "@backstage/core-app-api": "^0.1.4", - "@backstage/dev-utils": "^0.2.2", - "@backstage/test-utils": "^0.1.14", + "@backstage/cli": "^0.7.6", + "@backstage/core-app-api": "^0.1.6", + "@backstage/dev-utils": "^0.2.4", + "@backstage/test-utils": "^0.1.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts similarity index 100% rename from plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts rename to plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts diff --git a/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts similarity index 100% rename from plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts rename to plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts diff --git a/plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/index.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/index.ts similarity index 100% rename from plugins/analytics-provider-ga/src/apis/implementations/AnalyticsApi/index.ts rename to plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/index.ts diff --git a/plugins/analytics-provider-ga/src/index.ts b/plugins/analytics-module-ga/src/index.ts similarity index 92% rename from plugins/analytics-provider-ga/src/index.ts rename to plugins/analytics-module-ga/src/index.ts index 8dbf2d6210..d9d3222ac0 100644 --- a/plugins/analytics-provider-ga/src/index.ts +++ b/plugins/analytics-module-ga/src/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { analyticsProviderGA } from './plugin'; +export { analyticsModuleGA } from './plugin'; diff --git a/plugins/analytics-provider-ga/src/plugin.test.ts b/plugins/analytics-module-ga/src/plugin.test.ts similarity index 87% rename from plugins/analytics-provider-ga/src/plugin.test.ts rename to plugins/analytics-module-ga/src/plugin.test.ts index 92d12ea6db..394e5fc070 100644 --- a/plugins/analytics-provider-ga/src/plugin.test.ts +++ b/plugins/analytics-module-ga/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { analyticsProviderGA } from './plugin'; +import { analyticsModuleGA } from './plugin'; describe('google-analytics', () => { it('should export plugin', () => { - expect(analyticsProviderGA).toBeDefined(); + expect(analyticsModuleGA).toBeDefined(); }); }); diff --git a/plugins/analytics-provider-ga/src/plugin.ts b/plugins/analytics-module-ga/src/plugin.ts similarity index 95% rename from plugins/analytics-provider-ga/src/plugin.ts rename to plugins/analytics-module-ga/src/plugin.ts index e84dbbcd04..e339965d91 100644 --- a/plugins/analytics-provider-ga/src/plugin.ts +++ b/plugins/analytics-module-ga/src/plugin.ts @@ -21,7 +21,7 @@ import { } from '@backstage/core-plugin-api'; import { GoogleAnalytics } from './apis/implementations/AnalyticsApi'; -export const analyticsProviderGA = createPlugin({ +export const analyticsModuleGA = createPlugin({ id: 'analytics-provider-ga', apis: [ createApiFactory({ diff --git a/plugins/analytics-provider-ga/src/setupTests.ts b/plugins/analytics-module-ga/src/setupTests.ts similarity index 100% rename from plugins/analytics-provider-ga/src/setupTests.ts rename to plugins/analytics-module-ga/src/setupTests.ts diff --git a/plugins/analytics-provider-ga/dev/app-config.yaml b/plugins/analytics-provider-ga/dev/app-config.yaml deleted file mode 100644 index 3d5b23a356..0000000000 --- a/plugins/analytics-provider-ga/dev/app-config.yaml +++ /dev/null @@ -1,3 +0,0 @@ -app: - analytics: - provider: ga diff --git a/plugins/search/src/components/SearchPage/SearchPage.test.tsx b/plugins/search/src/components/SearchPage/SearchPage.test.tsx index 5ff61a3dbe..bc3b8ceb0b 100644 --- a/plugins/search/src/components/SearchPage/SearchPage.test.tsx +++ b/plugins/search/src/components/SearchPage/SearchPage.test.tsx @@ -75,8 +75,8 @@ describe('SearchPage', () => { const expectedFilters = { [expectedFilterField]: expectedFilterValue }; const expectedPageCursor = 'SOMEPAGE'; - // e.g. ?query=petstore&pageCursor=SOMEPAGE&filters[lifecycle][]=experimental&filters[kind]=Component - (useLocation as jest.Mock).mockReturnValueOnce({ + // e.g. ?query=petstore&pageCursor=1&filters[lifecycle][]=experimental&filters[kind]=Component + (useLocation as jest.Mock).mockReturnValue({ search: `?query=${expectedTerm}&types[]=${expectedTypes[0]}&filters[${expectedFilterField}]=${expectedFilterValue}&pageCursor=${expectedPageCursor}`, }); From dffa1892cd61abaf534d937d743637f05086b3d0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 13 Aug 2021 16:44:15 +0200 Subject: [PATCH 08/25] Simplify Analytics API from analytics module POV Signed-off-by: Eric Peterson --- packages/core-plugin-api/api-report.md | 6 +-- .../src/analytics/useAnalytics.test.tsx | 24 +++++----- .../src/analytics/useAnalytics.tsx | 16 ++++++- .../src/apis/definitions/AnalyticsApi.ts | 9 ++-- packages/test-utils/api-report.md | 12 ++--- .../AnalyticsApi/MockAnalyticsApi.test.ts | 13 +++-- .../apis/AnalyticsApi/MockAnalyticsApi.ts | 30 +++++------- .../AnalyticsApi/GoogleAnalytics.test.ts | 47 +++++++++++++------ .../AnalyticsApi/GoogleAnalytics.ts | 29 ++++-------- 9 files changed, 100 insertions(+), 86 deletions(-) diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 7c833d2b66..2628c13ad1 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -38,11 +38,7 @@ export type AlertMessage = { // // @public export type AnalyticsApi = { - getDecoratedTracker({ - domain, - }: { - domain: AnalyticsDomainValue; - }): AnalyticsTracker; + captureEvent(event: DomainDecoratedAnalyticsEvent): void; }; // Warning: (ae-missing-release-tag) "analyticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx index 33e71aa2e4..d8d6706a9e 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx @@ -35,27 +35,27 @@ describe('useAnalytics', () => { }); it('returns tracker from defined analytics api', () => { - const expectedFunction = 'capture function'; - const getDecoratedTracker = jest.fn().mockReturnValue({ - captureEvent: expectedFunction, - }); + const captureEvent = jest.fn(); // Simulate useApi returning a valid tracker. - mocked(useApi).mockReturnValue({ getDecoratedTracker }); + mocked(useApi).mockReturnValue({ captureEvent }); - // The getDecoratedTracker method of the underlying implementation should - // have been called with the domain provided. + // Calling the captureEvent method of the underlying implementation should + // pass along the given event as well as the default domain. const { result } = renderHook(() => useAnalytics()); - expect(getDecoratedTracker).toHaveBeenCalledWith({ + result.current.captureEvent('a verb', 'a noun', 42, { some: 'value' }); + expect(captureEvent).toHaveBeenCalledWith({ + verb: 'a verb', + noun: 'a noun', + value: 42, + context: { + some: 'value', + }, domain: { componentName: 'App', pluginId: 'root', routeRef: 'unknown', }, }); - - // And the returned tracker's captureEvent should have come from the API - // implementation. - expect(result.current.captureEvent).toBe(expectedFunction); }); }); diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx index e3dc851321..8643747f35 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -24,7 +24,21 @@ import { useApi } from '../apis'; function useTracker(): AnalyticsTracker { const analyticsApi = useApi(analyticsApiRef); const domain = useAnalyticsDomain(); - return analyticsApi.getDecoratedTracker({ domain }); + return { + captureEvent: (verb, noun, value, context) => { + try { + analyticsApi.captureEvent({ + verb, + noun, + value, + context, + domain, + }); + } catch { + // Just don't throw on an instrumentation problem. + } + }, + }; } /** diff --git a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts index 0e5f87b038..4601077835 100644 --- a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -109,13 +109,10 @@ export type AnalyticsTracker = { */ export type AnalyticsApi = { /** - * Retrieves a tracker decorated with a given analytics domain. + * Primary event handler responsible for compiling and forwarding events to + * an analytics system. */ - getDecoratedTracker({ - domain, - }: { - domain: AnalyticsDomainValue; - }): AnalyticsTracker; + captureEvent(event: DomainDecoratedAnalyticsEvent): void; }; export const analyticsApiRef: ApiRef = createApiRef({ diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index 9c6bc4d999..d439efd0f9 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -4,8 +4,6 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; -import { AnalyticsDomainValue } from '@backstage/core-plugin-api'; -import { AnalyticsTracker } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { DomainDecoratedAnalyticsEvent } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; @@ -24,11 +22,13 @@ import { StorageValueChange } from '@backstage/core-plugin-api'; // @public (undocumented) export class MockAnalyticsApi implements AnalyticsApi { // (undocumented) - getDecoratedTracker({ + captureEvent({ + verb, + noun, + value, + context, domain, - }: { - domain: AnalyticsDomainValue; - }): AnalyticsTracker; + }: DomainDecoratedAnalyticsEvent): void; // (undocumented) getEvents(): DomainDecoratedAnalyticsEvent[]; } diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts index 4113940072..fdd9478c5d 100644 --- a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts @@ -23,11 +23,16 @@ describe('MockAnalyticsApi', () => { it('should collect events', () => { const api = new MockAnalyticsApi(); - const tracker = api.getDecoratedTracker({ domain }); - tracker.captureEvent('verb-1', 'noun-1'); - tracker.captureEvent('verb-2', 'noun-2', 42); - tracker.captureEvent('verb-3', 'noun-3', 1337, { some: 'context' }); + api.captureEvent({ verb: 'verb-1', noun: 'noun-1', domain }); + api.captureEvent({ verb: 'verb-2', noun: 'noun-2', value: 42, domain }); + api.captureEvent({ + verb: 'verb-3', + noun: 'noun-3', + value: 1337, + context: { some: 'context' }, + domain, + }); expect(api.getEvents()[0]).toMatchObject({ noun: 'noun-1', diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts index 7e8e19d438..b90290ff6a 100644 --- a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts @@ -16,30 +16,26 @@ import { AnalyticsApi, - AnalyticsDomainValue, - AnalyticsTracker, DomainDecoratedAnalyticsEvent, } from '@backstage/core-plugin-api'; export class MockAnalyticsApi implements AnalyticsApi { private events: DomainDecoratedAnalyticsEvent[] = []; - getDecoratedTracker({ + captureEvent({ + verb, + noun, + value, + context, domain, - }: { - domain: AnalyticsDomainValue; - }): AnalyticsTracker { - return { - captureEvent: (verb, noun, value, context) => { - this.events.push({ - verb, - noun, - domain, - ...(value !== undefined ? { value } : {}), - ...(context !== undefined ? { context } : {}), - }); - }, - }; + }: DomainDecoratedAnalyticsEvent) { + this.events.push({ + verb, + noun, + domain, + ...(value !== undefined ? { value } : {}), + ...(context !== undefined ? { context } : {}), + }); } getEvents(): DomainDecoratedAnalyticsEvent[] { 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 index 220ff5261b..da319d365c 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -38,7 +38,7 @@ describe('GoogleAnalytics', () => { it('returns implementation', () => { const api = GoogleAnalytics.fromConfig(basicValidConfig); - expect(api.getDecoratedTracker).toBeDefined(); + expect(api.captureEvent).toBeDefined(); // Initializes GA with tracking ID. expect(ReactGA.testModeAPI.calls[0]).toEqual([ @@ -94,8 +94,11 @@ describe('GoogleAnalytics', () => { it('tracks basic pageview', () => { const api = GoogleAnalytics.fromConfig(basicValidConfig); - const tracker = api.getDecoratedTracker({ domain }); - tracker.captureEvent('navigate', '/'); + api.captureEvent({ + verb: 'navigate', + noun: '/', + domain, + }); const [command, data] = ReactGA.testModeAPI.calls[1]; expect(command).toBe('send'); @@ -107,12 +110,16 @@ describe('GoogleAnalytics', () => { it('tracks basic event', () => { const api = GoogleAnalytics.fromConfig(basicValidConfig); - const tracker = api.getDecoratedTracker({ domain }); const expectedAction = 'click'; const expectedLabel = 'on something'; const expectedValue = 42; - tracker.captureEvent(expectedAction, expectedLabel, expectedValue); + api.captureEvent({ + verb: expectedAction, + noun: expectedLabel, + value: expectedValue, + domain, + }); const [command, data] = ReactGA.testModeAPI.calls[1]; expect(command).toBe('send'); @@ -127,8 +134,11 @@ describe('GoogleAnalytics', () => { it('captures configured custom dimensions/metrics on pageviews', () => { const api = GoogleAnalytics.fromConfig(advancedConfig); - const tracker = api.getDecoratedTracker({ domain }); - tracker.captureEvent('navigate', '/a-page'); + api.captureEvent({ + verb: 'navigate', + noun: '/a-page', + domain, + }); // Expect a set command first. const [setCommand, setData] = ReactGA.testModeAPI.calls[1]; @@ -149,14 +159,19 @@ describe('GoogleAnalytics', () => { it('captures configured custom dimensions/metrics on events', () => { const api = GoogleAnalytics.fromConfig(advancedConfig); - const tracker = api.getDecoratedTracker({ domain }); const expectedAction = 'search'; const expectedLabel = 'some query'; const expectedValue = 5; - tracker.captureEvent(expectedAction, expectedLabel, expectedValue, { - extraDimension: false, - extraMetric: 0, + api.captureEvent({ + verb: expectedAction, + noun: expectedLabel, + value: expectedValue, + context: { + extraDimension: false, + extraMetric: 0, + }, + domain, }); const [command, data] = ReactGA.testModeAPI.calls[1]; @@ -176,10 +191,14 @@ describe('GoogleAnalytics', () => { it('does not pass non-numeric data on metrics', () => { const api = GoogleAnalytics.fromConfig(advancedConfig); - const tracker = api.getDecoratedTracker({ domain }); - tracker.captureEvent('verb', 'noun', undefined, { - extraMetric: 'not a number', + api.captureEvent({ + verb: 'verb', + noun: 'noun', + context: { + extraMetric: 'not a number', + }, + domain, }); const [, data] = ReactGA.testModeAPI.calls[1]; diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index d02924d138..e721dc5e01 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -17,9 +17,9 @@ import ReactGA from 'react-ga'; import { AnalyticsApi, - AnalyticsTracker, AnalyticsDomainValue, AnalyticsEventContext, + DomainDecoratedAnalyticsEvent, } from '@backstage/core-plugin-api'; import { Config } from '@backstage/config'; @@ -92,31 +92,18 @@ export class GoogleAnalytics implements AnalyticsApi { }); } - /** - * Returns the Google Analytics tracker to API consumers. - */ - getDecoratedTracker({ - domain, - }: { - domain: AnalyticsDomainValue; - }): AnalyticsTracker { - return { - captureEvent: (...args) => this.captureEvent(domain, ...args), - }; - } - /** * 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). */ - private captureEvent( - domain: AnalyticsDomainValue, - verb: string, - noun: string, - value?: number, - context?: AnalyticsEventContext, - ) { + captureEvent({ + domain, + verb, + noun, + value, + context, + }: DomainDecoratedAnalyticsEvent) { const customMetadata = this.getCustomDimensionMetrics(domain, context); if (verb === 'navigate' && domain?.componentName === 'App') { From 1de92b66ac565925b013eae2fa7abe8218b3291e Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 13 Aug 2021 17:22:09 +0200 Subject: [PATCH 09/25] Fix package.json Signed-off-by: Eric Peterson --- plugins/analytics-module-ga/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 464151b614..d4e7b079b0 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -21,8 +21,8 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.2.0", - "@backstage/core-plugin-api": "^0.1.4", + "@backstage/core-components": "^0.3.1", + "@backstage/core-plugin-api": "^0.1.6", "@backstage/theme": "^0.2.9", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -33,10 +33,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.6", - "@backstage/core-app-api": "^0.1.6", - "@backstage/dev-utils": "^0.2.4", - "@backstage/test-utils": "^0.1.16", + "@backstage/cli": "^0.7.8", + "@backstage/core-app-api": "^0.1.8", + "@backstage/dev-utils": "^0.2.6", + "@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", From 2e0adeb664c7c828e0390f221b022a0cdf080d94 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 5 Sep 2021 21:38:00 +0200 Subject: [PATCH 10/25] Self-review Signed-off-by: Eric Peterson --- packages/core-components/src/components/Link/Link.test.tsx | 2 +- packages/core-components/src/components/Link/Link.tsx | 2 -- plugins/search/src/components/SearchPage/SearchPage.test.tsx | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index 0be4749f0a..504de6bb9a 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -18,10 +18,10 @@ import React from 'react'; import { render, fireEvent } 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'; -import { analyticsApiRef } from '../../../../core-plugin-api/src'; describe('', () => { it('navigates using react-router', async () => { diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index b985fcb922..7b26ea5037 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -37,8 +37,6 @@ 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 * Thin wrapper on top of material-ui's Link component, which... * - Makes the Link use react-router * - Captures Link clicks as analytics events. diff --git a/plugins/search/src/components/SearchPage/SearchPage.test.tsx b/plugins/search/src/components/SearchPage/SearchPage.test.tsx index bc3b8ceb0b..c9980b3bf7 100644 --- a/plugins/search/src/components/SearchPage/SearchPage.test.tsx +++ b/plugins/search/src/components/SearchPage/SearchPage.test.tsx @@ -75,7 +75,7 @@ describe('SearchPage', () => { const expectedFilters = { [expectedFilterField]: expectedFilterValue }; const expectedPageCursor = 'SOMEPAGE'; - // e.g. ?query=petstore&pageCursor=1&filters[lifecycle][]=experimental&filters[kind]=Component + // e.g. ?query=petstore&pageCursor=SOMEPAGE&filters[lifecycle][]=experimental&filters[kind]=Component (useLocation as jest.Mock).mockReturnValue({ search: `?query=${expectedTerm}&types[]=${expectedTypes[0]}&filters[${expectedFilterField}]=${expectedFilterValue}&pageCursor=${expectedPageCursor}`, }); From b45408391cd30546073915ad60d59bcd2400af5c Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 6 Sep 2021 22:43:11 +0200 Subject: [PATCH 11/25] Clearer naming. Signed-off-by: Eric Peterson --- packages/core-app-api/src/routing/RouteTracker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index cce4692928..1114bdcc70 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -86,7 +86,7 @@ const getExtensionDomain = ( /** * Performs the actual event capture on render. */ -const CaptureOnRender = ({ +const TrackNavigation = ({ pathname, search, hash, @@ -115,7 +115,7 @@ export const RouteTracker = ({ objects }: { objects: RouteObjects }) => { return ( - + ); }; From f95936d28935536d4321a72bfbca89b6c0b7fd91 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 6 Sep 2021 22:47:55 +0200 Subject: [PATCH 12/25] Bump packages to match new reality. Signed-off-by: Eric Peterson --- plugins/analytics-module-ga/package.json | 10 +++++----- yarn.lock | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index d4e7b079b0..ab55ec9a8a 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.3.1", + "@backstage/core-components": "^0.4.0", "@backstage/core-plugin-api": "^0.1.6", - "@backstage/theme": "^0.2.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.45", @@ -33,9 +33,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.8", - "@backstage/core-app-api": "^0.1.8", - "@backstage/dev-utils": "^0.2.6", + "@backstage/cli": "^0.7.10", + "@backstage/core-app-api": "^0.1.11", + "@backstage/dev-utils": "^0.2.8", "@backstage/test-utils": "^0.1.17", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/yarn.lock b/yarn.lock index 0a97f1435d..8938c7f923 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23031,7 +23031,7 @@ react-fast-compare@^2.0.4: resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -react-fast-compare@^3.0.1, react-fast-compare@^3.1.1: +react-fast-compare@^3.0.1, react-fast-compare@^3.1.1, react-fast-compare@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== From 83c163a2343b413842392ea83004e00e389008e5 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 12 Sep 2021 20:37:52 +0200 Subject: [PATCH 13/25] Minor changeset and other improvements. Signed-off-by: Eric Peterson --- .changeset/analytics-glimmering-radio-station.md | 9 --------- .changeset/analytics-old-skipping-record.md | 2 +- packages/core-app-api/config.d.ts | 12 ------------ .../core-plugin-api/src/analytics/useAnalytics.tsx | 5 +++-- plugins/analytics-module-ga/package.json | 12 ++++++------ 5 files changed, 10 insertions(+), 30 deletions(-) delete mode 100644 .changeset/analytics-glimmering-radio-station.md diff --git a/.changeset/analytics-glimmering-radio-station.md b/.changeset/analytics-glimmering-radio-station.md deleted file mode 100644 index 2bc5aef678..0000000000 --- a/.changeset/analytics-glimmering-radio-station.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@backstage/plugin-analytics-module-ga': patch ---- - -Initial Google Analytics implementation for the Backstage Analytics API, which: - -- Handles pageview and custom event instrumentation idiomatically. -- Enables custom dimension and metric collection via app config. -- Includes configurable debug/test mode for non-production environments. diff --git a/.changeset/analytics-old-skipping-record.md b/.changeset/analytics-old-skipping-record.md index a1ee11af84..9bfa58fd57 100644 --- a/.changeset/analytics-old-skipping-record.md +++ b/.changeset/analytics-old-skipping-record.md @@ -1,5 +1,5 @@ --- -'@backstage/core-plugin-api': minor +'@backstage/core-plugin-api': patch --- Introducing the Analytics API: a lightweight way for plugins to instrument key diff --git a/packages/core-app-api/config.d.ts b/packages/core-app-api/config.d.ts index a2fa3f1989..d88c818d11 100644 --- a/packages/core-app-api/config.d.ts +++ b/packages/core-app-api/config.d.ts @@ -65,18 +65,6 @@ export interface Config { }>; }>; }; - - /** - * Information about how analytics events should be collected in this - * Backstage Instance. - */ - analytics?: { - /** - * The provider used to collect instrumented events. Further - * configuration values depend on the provider specified here. - */ - provider: string; - }; }; /** diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx index 8643747f35..b120689e2e 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -34,8 +34,9 @@ function useTracker(): AnalyticsTracker { context, domain, }); - } catch { - // Just don't throw on an instrumentation problem. + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Error during analytics event capture. %o', e); } }, }; diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index ab55ec9a8a..1618129d90 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -21,21 +21,21 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.4.0", - "@backstage/core-plugin-api": "^0.1.6", + "@backstage/core-components": "^0.4.2", + "@backstage/core-plugin-api": "^0.1.8", "@backstage/theme": "^0.2.10", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", - "@material-ui/lab": "4.0.0-alpha.45", + "@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.10", - "@backstage/core-app-api": "^0.1.11", - "@backstage/dev-utils": "^0.2.8", + "@backstage/cli": "^0.7.12", + "@backstage/core-app-api": "^0.1.13", + "@backstage/dev-utils": "^0.2.9", "@backstage/test-utils": "^0.1.17", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", From 1ff738629165050e957a4206d7e5b88ea6655cb0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 16 Sep 2021 14:16:16 +0200 Subject: [PATCH 14/25] Simplify/clarify Analytics API types and naming. Signed-off-by: Eric Peterson --- ...ytics-haunts-dismembered-constellations.md | 11 ++- .changeset/analytics-old-skipping-record.md | 14 ++-- .changeset/analytics-sings-stormy-weather.md | 12 ++-- packages/core-app-api/src/app/App.test.tsx | 15 ++-- .../core-app-api/src/routing/RouteTracker.tsx | 20 +++--- .../src/components/Link/Link.test.tsx | 6 +- .../src/components/Link/Link.tsx | 4 +- packages/core-plugin-api/api-report.md | 70 +++++++----------- ...ain.test.tsx => AnalyticsContext.test.tsx} | 72 ++++++++++--------- ...alyticsDomain.tsx => AnalyticsContext.tsx} | 49 +++++++------ .../core-plugin-api/src/analytics/index.ts | 9 +-- .../core-plugin-api/src/analytics/types.ts | 35 +++------ .../src/analytics/useAnalytics.test.tsx | 17 +++-- .../src/analytics/useAnalytics.tsx | 12 ++-- .../src/apis/definitions/AnalyticsApi.ts | 52 +++++++------- .../src/extensions/extensions.test.tsx | 22 +++--- .../src/extensions/extensions.tsx | 6 +- packages/test-utils/api-report.md | 12 ++-- .../AnalyticsApi/MockAnalyticsApi.test.ts | 39 +++++----- .../apis/AnalyticsApi/MockAnalyticsApi.ts | 25 +++---- plugins/analytics-module-ga/README.md | 20 +++--- plugins/analytics-module-ga/config.d.ts | 13 ++-- .../analytics-module-ga/dev/Playground.tsx | 6 +- .../AnalyticsApi/GoogleAnalytics.test.ts | 64 ++++++++--------- .../AnalyticsApi/GoogleAnalytics.ts | 44 ++++++------ 25 files changed, 305 insertions(+), 344 deletions(-) rename packages/core-plugin-api/src/analytics/{AnalyticsDomain.test.tsx => AnalyticsContext.test.tsx} (54%) rename packages/core-plugin-api/src/analytics/{AnalyticsDomain.tsx => AnalyticsContext.tsx} (50%) diff --git a/.changeset/analytics-haunts-dismembered-constellations.md b/.changeset/analytics-haunts-dismembered-constellations.md index cc685d68f0..6acf7d8bca 100644 --- a/.changeset/analytics-haunts-dismembered-constellations.md +++ b/.changeset/analytics-haunts-dismembered-constellations.md @@ -9,15 +9,12 @@ metadata, allowing clicks to be attributed to the plugin containing the link: ```json { - "verb": "click", - "noun": "/value/of-the/to-prop/passed-to-the-link", - "domain": { - "componentName": "Link", + "action": "click", + "subject": "/value/of-the/to-prop/passed-to-the-link", + "context": { + "componentName": "SomeAssociatedExtension", "pluginId": "plugin-in-which-link-was-clicked", "routeRef": "any-associated-route-ref-id" } } ``` - -These events can be identified and handled by checking for the verb `click` -and the componentName `Link`. diff --git a/.changeset/analytics-old-skipping-record.md b/.changeset/analytics-old-skipping-record.md index 9bfa58fd57..a0cacdf693 100644 --- a/.changeset/analytics-old-skipping-record.md +++ b/.changeset/analytics-old-skipping-record.md @@ -9,14 +9,14 @@ 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 a verb (the action performed), a noun (a unique - identifier of the object the action is being taken on), and optionally a - numeric metric/value. -- ``, a way to declaratively attach additional information + 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 - `withAnalyticsDomain()` HOC utility. -- The `tracker.captureEvent()` method also accepts a `context` object for - optionally providing additional run-time information about an event. + `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 diff --git a/.changeset/analytics-sings-stormy-weather.md b/.changeset/analytics-sings-stormy-weather.md index 2a0d83e8a4..a04e824b60 100644 --- a/.changeset/analytics-sings-stormy-weather.md +++ b/.changeset/analytics-sings-stormy-weather.md @@ -6,13 +6,13 @@ The Core App API now automatically instruments all route location changes using the new Analytics API. Each location change triggers a `navigate` event, which is an analogue of a "pageview" event in traditional web analytics systems. In addition to the path, these events provide plugin-level metadata via the -analytics domain, which can be useful for analyzing plugin usage: +analytics context, which can be useful for analyzing plugin usage: ```json { - "verb": "navigate", - "noun": "/the-path/navigated/to?with=params#and-hashes", - "domain": { + "action": "navigate", + "subject": "/the-path/navigated/to?with=params#and-hashes", + "context": { "componentName": "App", "pluginId": "id-of-plugin-that-exported-the-route", "routeRef": "associated-route-ref-id" @@ -20,5 +20,5 @@ analytics domain, which can be useful for analyzing plugin usage: } ``` -These events can be identified and handled by checking for the verb `navigate` -and the componentName `App`. +These events can be identified and handled by checking for the action +`navigate` and the componentName `App`. diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/App.test.tsx index 93b711036b..b04cf3edbf 100644 --- a/packages/core-app-api/src/app/App.test.tsx +++ b/packages/core-app-api/src/app/App.test.tsx @@ -377,22 +377,21 @@ describe('Integration Test', () => { , ); - // Capture initial and subsequent navigation events with expected domain - // values. + // Capture initial and subsequent navigation events with expected context. const capturedEvents = mockAnalyticsApi.getEvents(); expect(capturedEvents[0]).toMatchObject({ - verb: 'navigate', - noun: '/', - domain: { + action: 'navigate', + subject: '/', + context: { componentName: 'App', pluginId: 'blob', routeRef: 'ref-1-2', }, }); expect(capturedEvents[1]).toMatchObject({ - verb: 'navigate', - noun: '/foo', - domain: { + action: 'navigate', + subject: '/foo', + context: { componentName: 'App', pluginId: 'plugin2', routeRef: 'ref-2', diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index 1114bdcc70..8ca76be78e 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -24,23 +24,23 @@ import { BackstagePlugin, useAnalytics, getComponentData, - AnalyticsDomain, - RoutableAnalyticsDomain, + AnalyticsContext, + CommonAnalyticsContext, } from '@backstage/core-plugin-api'; type RouteObjects = ReturnType; /** - * Returns an extension domain given the current pathname and a RouteObject + * Returns an extension context given the current pathname and a RouteObject * that defines all registered routes in react. * * If no exact match is found, path parts are stripped away, one-by-one, until * a parent-level path matches a route. */ -const getExtensionDomain = ( +const getExtensionContext = ( pathname: string, routes: RouteObjects, -): RoutableAnalyticsDomain | {} => { +): CommonAnalyticsContext | {} => { try { const cleanPath = pathname.replace(/\/+$/, ''); const matches = matchRoutes(routes, { pathname }); @@ -76,7 +76,7 @@ const getExtensionDomain = ( // Try again, one path-level shallower. const nextLevelPath = cleanPath.split('/').slice(0, -1).join('/'); return nextLevelPath !== '' - ? getExtensionDomain(nextLevelPath, routes) + ? getExtensionContext(nextLevelPath, routes) : {}; } catch { return {}; @@ -106,16 +106,16 @@ const TrackNavigation = ({ }; /** - * Logs a "navigate" event with appropriate plugin-level analytics domain + * Logs a "navigate" event with appropriate plugin-level analytics context * attributes each time the user navigates to a page. */ export const RouteTracker = ({ objects }: { objects: RouteObjects }) => { const { pathname, search, hash } = useLocation(); - const attributes = getExtensionDomain(pathname, objects); + const attributes = getExtensionContext(pathname, objects); return ( - + - + ); }; diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index 504de6bb9a..e2e8612fc9 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -63,9 +63,9 @@ describe('', () => { // Analytics event should have been fired. expect(analyticsApi.getEvents()[0]).toMatchObject({ - verb: 'click', - noun: '/test', - domain: { componentName: 'Link' }, + action: 'click', + subject: '/test', + context: { componentName: 'Link' }, }); // Custom onClick handler should have still been fired too. diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index 7b26ea5037..1134bc1b07 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useAnalytics, withAnalyticsDomain } from '@backstage/core-plugin-api'; +import { useAnalytics, withAnalyticsContext } from '@backstage/core-plugin-api'; import { Link as MaterialLink, LinkProps as MaterialLinkProps, @@ -41,7 +41,7 @@ declare function LinkType(props: LinkProps): JSX.Element; * - Makes the Link use react-router * - Captures Link clicks as analytics events. */ -const ActualLink = withAnalyticsDomain( +const ActualLink = withAnalyticsContext( ({ inputRef, onClick, ...props }: LinkProps & { inputRef: OptionalRef }) => { const analytics = useAnalytics(); const to = String(props.to); diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 2628c13ad1..d531244cd7 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -38,7 +38,7 @@ export type AlertMessage = { // // @public export type AnalyticsApi = { - captureEvent(event: DomainDecoratedAnalyticsEvent): void; + 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) @@ -46,38 +46,40 @@ export type AnalyticsApi = { // @public (undocumented) export const analyticsApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "AnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// 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 AnalyticsDomain: ({ +export const AnalyticsContext: ({ attributes, children, }: { - attributes: AnalyticsDomainValue; + attributes: AnalyticsContextValue; children: ReactNode; }) => JSX.Element; -// Warning: (ae-missing-release-tag) "AnalyticsDomainValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-forgotten-export) The symbol "AnyAnalyticsContext" needs to be exported by the entry point index.d.ts +// 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 AnalyticsDomainValue = Partial< - RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain +export type AnalyticsContextValue = Partial< + 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 = { - verb: string; - noun: string; + action: string; + subject: string; value?: number; - context?: AnalyticsEventContext; + attributes?: AnalyticsEventAttributes; + context: AnalyticsContextValue; }; -// Warning: (ae-missing-release-tag) "AnalyticsEventContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// 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 AnalyticsEventContext = { +export type AnalyticsEventAttributes = { [attribute in string]: string | boolean | number; }; @@ -86,20 +88,15 @@ export type AnalyticsEventContext = { // @public export type AnalyticsTracker = { captureEvent: ( - verb: string, - noun: string, - value?: number, - context?: AnalyticsEventContext, + action: string, + subject: string, + options?: { + value?: number; + attributes?: AnalyticsEventAttributes; + }, ) => void; }; -// Warning: (ae-missing-release-tag) "AnyAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export type AnyAnalyticsDomain = { - [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) @@ -300,11 +297,12 @@ export type BootErrorPageProps = { error: Error; }; -// Warning: (ae-missing-release-tag) "ComponentAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// 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 ComponentAnalyticsDomain = { +export type CommonAnalyticsContext = { pluginId: string; + routeRef: string; componentName: string; }; @@ -440,13 +438,6 @@ export type DiscoveryApi = { // @public (undocumented) export const discoveryApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "DomainDecoratedAnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export type DomainDecoratedAnalyticsEvent = AnalyticsEvent & { - domain: AnalyticsDomainValue; -}; - // Warning: (ae-missing-release-tag) "ElementCollection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -790,15 +781,6 @@ export type ProfileInfoApi = { getProfile(options?: AuthRequestOptions): Promise; }; -// Warning: (ae-missing-release-tag) "RoutableAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export type RoutableAnalyticsDomain = { - pluginId: string; - routeRef: string; - componentName: string; -}; - // Warning: (ae-missing-release-tag) "RouteOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -982,12 +964,12 @@ export function useRouteRefParams( _routeRef: RouteRef | SubRouteRef, ): Params; -// Warning: (ae-missing-release-tag) "withAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "withAnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public -export function withAnalyticsDomain

( +export function withAnalyticsContext

( Component: React_2.ComponentType

, - domain: AnyAnalyticsDomain, + values: AnalyticsContextValue, ): { (props: P): JSX.Element; displayName: string; diff --git a/packages/core-plugin-api/src/analytics/AnalyticsDomain.test.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx similarity index 54% rename from packages/core-plugin-api/src/analytics/AnalyticsDomain.test.tsx rename to packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx index e83ae478e4..cad6d5ec38 100644 --- a/packages/core-plugin-api/src/analytics/AnalyticsDomain.test.tsx +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx @@ -18,27 +18,27 @@ import React from 'react'; import { render } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import { - AnalyticsDomain, - useAnalyticsDomain, - withAnalyticsDomain, -} from './AnalyticsDomain'; + AnalyticsContext, + useAnalyticsContext, + withAnalyticsContext, +} from './AnalyticsContext'; -const DomainSpy = () => { - const domain = useAnalyticsDomain(); +const AnalyticsSpy = () => { + const context = useAnalyticsContext(); return ( <> -

{domain.routeRef}
-
{domain.pluginId}
-
{domain.componentName}
-
{domain.custom}
+
{context.routeRef}
+
{context.pluginId}
+
{context.componentName}
+
{context.custom}
); }; -describe('AnalyticsDomain', () => { - describe('useAnalyticsDomain', () => { +describe('AnalyticsContext', () => { + describe('useAnalyticsContext', () => { it('returns default values', () => { - const { result } = renderHook(() => useAnalyticsDomain()); + const { result } = renderHook(() => useAnalyticsContext()); expect(result.current).toEqual({ componentName: 'App', pluginId: 'root', @@ -47,12 +47,12 @@ describe('AnalyticsDomain', () => { }); }); - describe('AnalyticsDomain', () => { - it('uses default analytics domain', () => { + describe('AnalyticsContext', () => { + it('uses default analytics context', () => { const result = render( - - - , + + + , ); expect(result.getByTestId('component-name')).toHaveTextContent('App'); @@ -60,11 +60,11 @@ describe('AnalyticsDomain', () => { expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); }); - it('uses provided analytics domain', () => { + it('uses provided analytics context', () => { const result = render( - - - , + + + , ); expect(result.getByTestId('component-name')).toHaveTextContent('App'); @@ -72,29 +72,33 @@ describe('AnalyticsDomain', () => { expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); }); - it('uses nested analytics domain', () => { + it('uses nested analytics context', () => { const result = render( - - - - - , + + + + + , ); expect(result.getByTestId('component-name')).toHaveTextContent( - 'DomainSpy', + 'AnalyticsSpy', ); expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); }); }); - describe('withAnalyticsDomain', () => { - it('wraps component with analytics domain', () => { - const DomainSpyHOC = withAnalyticsDomain(DomainSpy, { custom: 'attr' }); - const result = render(); + describe('withAnalyticsContext', () => { + it('wraps component with analytics context', () => { + const AnalyticsSpyHOC = withAnalyticsContext(AnalyticsSpy, { + custom: 'attr', + }); + const result = render(); expect(result.getByTestId('custom')).toHaveTextContent('attr'); - expect(DomainSpyHOC.displayName).toBe('WithAnalyticsDomain(DomainSpy)'); + expect(AnalyticsSpyHOC.displayName).toBe( + 'WithAnalyticsContext(AnalyticsSpy)', + ); }); }); }); diff --git a/packages/core-plugin-api/src/analytics/AnalyticsDomain.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx similarity index 50% rename from packages/core-plugin-api/src/analytics/AnalyticsDomain.tsx rename to packages/core-plugin-api/src/analytics/AnalyticsContext.tsx index be1b90290c..f50375d42f 100644 --- a/packages/core-plugin-api/src/analytics/AnalyticsDomain.tsx +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx @@ -15,9 +15,9 @@ */ import React, { createContext, ReactNode, useContext } from 'react'; -import { AnalyticsDomainValue, AnyAnalyticsDomain } from './types'; +import { AnalyticsContextValue } from './types'; -export const AnalyticsDomainContext = createContext({ +const AnalyticsReactContext = createContext({ routeRef: 'unknown', pluginId: 'root', componentName: 'App', @@ -25,61 +25,60 @@ export const AnalyticsDomainContext = createContext({ /** * A "private" (to this package) hook that enables context inheritance and a - * way to read Analytics Domain values at event capture-time. + * way to read Analytics Context values at event capture-time. * @private */ -export const useAnalyticsDomain = () => { - return useContext(AnalyticsDomainContext); +export const useAnalyticsContext = () => { + return useContext(AnalyticsReactContext); }; /** - * Provides components in the child react tree an Analytics Domain, ensuring - * all analytics events captured within the domain have relevant contextual - * attributes. + * Provides components in the child react tree an Analytics Context, ensuring + * all analytics events captured within the context have relevant attributes. * - * Analytics domains are additive, meaning the domain ultimately emitted with - * an event is the combination of all domains in the parent tree. + * Analytics contexts are additive, meaning the context ultimately emitted with + * an event is the combination of all contexts in the parent tree. */ -export const AnalyticsDomain = ({ +export const AnalyticsContext = ({ attributes, children, }: { - attributes: AnalyticsDomainValue; + attributes: AnalyticsContextValue; children: ReactNode; }) => { - const parentValues = useAnalyticsDomain(); + const parentValues = useAnalyticsContext(); const combinedValue = { ...parentValues, ...attributes, }; return ( - + {children} - + ); }; /** - * Returns an HOC wrapping the provided component in an Analytics Domain with + * Returns an HOC wrapping the provided component in an Analytics context with * the given values. * - * @param Component - Component to be wrapped with analytics domain attributes. - * @param domain - Analytics domain key/value pairs. + * @param Component - Component to be wrapped with analytics context attributes + * @param values - Analytics context key/value pairs. */ -export function withAnalyticsDomain

( +export function withAnalyticsContext

( Component: React.ComponentType

, - domain: AnyAnalyticsDomain, + values: AnalyticsContextValue, ) { - const ComponentWithAnalyticsDomain = (props: P) => { + const ComponentWithAnalyticsContext = (props: P) => { return ( - + - + ); }; - ComponentWithAnalyticsDomain.displayName = `WithAnalyticsDomain(${ + ComponentWithAnalyticsContext.displayName = `WithAnalyticsContext(${ Component.displayName || Component.name || 'Component' })`; - return ComponentWithAnalyticsDomain; + return ComponentWithAnalyticsContext; } diff --git a/packages/core-plugin-api/src/analytics/index.ts b/packages/core-plugin-api/src/analytics/index.ts index 308069f9a8..dbf82aa4e3 100644 --- a/packages/core-plugin-api/src/analytics/index.ts +++ b/packages/core-plugin-api/src/analytics/index.ts @@ -14,11 +14,6 @@ * limitations under the License. */ -export { AnalyticsDomain, withAnalyticsDomain } from './AnalyticsDomain'; -export type { - AnalyticsDomainValue, - AnyAnalyticsDomain, - ComponentAnalyticsDomain, - RoutableAnalyticsDomain, -} from './types'; +export { AnalyticsContext, withAnalyticsContext } from './AnalyticsContext'; +export type { AnalyticsContextValue, 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 index acbda41da9..189d182328 100644 --- a/packages/core-plugin-api/src/analytics/types.ts +++ b/packages/core-plugin-api/src/analytics/types.ts @@ -15,50 +15,35 @@ */ /** - * Analytics domain covering routable extensions. + * Common analytics context attributes. */ -export type RoutableAnalyticsDomain = { +export type CommonAnalyticsContext = { /** - * The plugin that exposed the route. + * The associated plugin. */ pluginId: string; /** - * The ID of the route ref associated with the route. + * The ID of the associated route ref. */ routeRef: string; /** - * The name of the component used to render the route. + * The name of the associated component. */ componentName: string; }; /** - * Analytics domain covering component extensions. + * Allow arbitrary scalar values as context attributes too. */ -export type ComponentAnalyticsDomain = { - /** - * The plugin that exposed the component. - */ - pluginId: string; - - /** - * The name of the component. - */ - componentName: string; -}; - -/** - * Allow arbitrary scalar values as domain attributes too. - */ -export type AnyAnalyticsDomain = { +type AnyAnalyticsContext = { [param in string]: string | boolean | number | undefined; }; /** - * Common analytics domain attributes. + * Analytics context envelope. */ -export type AnalyticsDomainValue = Partial< - RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain +export type AnalyticsContextValue = Partial< + CommonAnalyticsContext & AnyAnalyticsContext >; diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx index d8d6706a9e..ce27602e6c 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx @@ -41,17 +41,20 @@ describe('useAnalytics', () => { mocked(useApi).mockReturnValue({ captureEvent }); // Calling the captureEvent method of the underlying implementation should - // pass along the given event as well as the default domain. + // pass along the given event as well as the default context. const { result } = renderHook(() => useAnalytics()); - result.current.captureEvent('a verb', 'a noun', 42, { some: 'value' }); - expect(captureEvent).toHaveBeenCalledWith({ - verb: 'a verb', - noun: 'a noun', + result.current.captureEvent('an action', 'a subject', { value: 42, - context: { + attributes: { some: 'value' }, + }); + expect(captureEvent).toHaveBeenCalledWith({ + action: 'an action', + subject: 'a subject', + value: 42, + attributes: { some: 'value', }, - domain: { + context: { componentName: '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 index b120689e2e..8b104e347c 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useAnalyticsDomain } from './AnalyticsDomain'; +import { useAnalyticsContext } from './AnalyticsContext'; import { analyticsApiRef, AnalyticsTracker, @@ -23,16 +23,16 @@ import { useApi } from '../apis'; function useTracker(): AnalyticsTracker { const analyticsApi = useApi(analyticsApiRef); - const domain = useAnalyticsDomain(); + const context = useAnalyticsContext(); return { - captureEvent: (verb, noun, value, context) => { + captureEvent: (action, subject, { value, attributes } = {}) => { try { analyticsApi.captureEvent({ - verb, - noun, + action, + subject, value, + attributes, context, - domain, }); } catch (e) { // eslint-disable-next-line no-console diff --git a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts index 4601077835..43e35607a3 100644 --- a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -15,20 +15,18 @@ */ import { ApiRef, createApiRef } from '../system'; -import { AnalyticsDomainValue } from '../../analytics/types'; +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. - * - * Note that attributes about the Backstage user or about the plugin tracking - * the event are inferred and captured separately on the Analytics Domain and - * do not need to be passed on the event itself. */ export type AnalyticsEvent = { /** * A string that identifies the event being tracked by the type of action the - * event represents. Examples include: + * 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 @@ -37,18 +35,18 @@ export type AnalyticsEvent = { * - hover * - scroll */ - verb: string; + action: string; /** - * A string that uniquely identifies the object that the verb or action is - * being taken on. Examples include: + * 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 */ - noun: string; + subject: string; /** * An optional numeric value relevant to the event that could be aggregated @@ -57,33 +55,29 @@ export type AnalyticsEvent = { * - 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 context with any additional dimensions or metrics that could be - * forwarded on to analytics systems. + * Optional, additional attributes (representing dimensions or metrics) + * specific to the event that could be forwarded on to analytics systems. */ - context?: AnalyticsEventContext; -}; + attributes?: AnalyticsEventAttributes; -/** - * An analytics event combined with domain attributes. - */ -export type DomainDecoratedAnalyticsEvent = AnalyticsEvent & { /** - * Domain metadata relating to where the event was captured and by whom. This - * could include information about the route, plugin, or component in which - * an event was captured. + * 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. */ - domain: AnalyticsDomainValue; + context: AnalyticsContextValue; }; /** * A structure allowing other arbitrary metadata to be provided by analytics * event emitters. */ -export type AnalyticsEventContext = { +export type AnalyticsEventAttributes = { [attribute in string]: string | boolean | number; }; @@ -93,10 +87,12 @@ export type AnalyticsEventContext = { */ export type AnalyticsTracker = { captureEvent: ( - verb: string, - noun: string, - value?: number, - context?: AnalyticsEventContext, + action: string, + subject: string, + options?: { + value?: number; + attributes?: AnalyticsEventAttributes; + }, ) => void; }; @@ -112,7 +108,7 @@ export type AnalyticsApi = { * Primary event handler responsible for compiling and forwarding events to * an analytics system. */ - captureEvent(event: DomainDecoratedAnalyticsEvent): void; + captureEvent(event: AnalyticsEvent): void; }; export const analyticsApiRef: ApiRef = createApiRef({ diff --git a/packages/core-plugin-api/src/extensions/extensions.test.tsx b/packages/core-plugin-api/src/extensions/extensions.test.tsx index 422b346a2c..42a2bf5e66 100644 --- a/packages/core-plugin-api/src/extensions/extensions.test.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.test.tsx @@ -17,7 +17,7 @@ import { withLogCollector } from '@backstage/test-utils-core'; import { render, screen } from '@testing-library/react'; import React from 'react'; -import { useAnalyticsDomain } from '../analytics/AnalyticsDomain'; +import { useAnalyticsContext } from '../analytics/AnalyticsContext'; import { useApp, ErrorBoundaryFallbackProps } from '../app'; import { createPlugin } from '../plugin'; import { createRouteRef } from '../routing'; @@ -109,19 +109,19 @@ describe('extensions', () => { expect(errors[0]).toMatch('Test error'); }); - it('should wrap extended component with analytics domain', async () => { - const DomainSpyExtension = plugin.provide( + it('should wrap extended component with analytics context', async () => { + const AnalyticsSpyExtension = plugin.provide( createReactExtension({ - name: 'DomainSpy', + name: 'AnalyticsSpy', component: { sync: () => { // eslint-disable-next-line react-hooks/rules-of-hooks - const domain = useAnalyticsDomain(); + const context = useAnalyticsContext(); return ( <> -

{domain.pluginId}
-
{domain.routeRef}
-
{domain.componentName}
+
{context.pluginId}
+
{context.routeRef}
+
{context.componentName}
); }, @@ -130,10 +130,12 @@ describe('extensions', () => { }), ); - const result = render(); + const result = render(); expect(result.getByTestId('plugin-id')).toHaveTextContent('my-plugin'); expect(result.getByTestId('route-ref')).toHaveTextContent('some-ref'); - expect(result.getByTestId('component-name')).toHaveTextContent('DomainSpy'); + expect(result.getByTestId('component-name')).toHaveTextContent( + 'AnalyticsSpy', + ); }); }); diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index 444ca6783c..7aa16f5692 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -15,7 +15,7 @@ */ import React, { lazy, Suspense } from 'react'; -import { AnalyticsDomain } from '../analytics/AnalyticsDomain'; +import { AnalyticsContext } from '../analytics/AnalyticsContext'; import { useApp } from '../app'; import { RouteRef, useRouteRef } from '../routing'; import { attachComponentData } from './componentData'; @@ -137,7 +137,7 @@ export function createReactExtension< return ( }> - - + ); diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index d439efd0f9..f4b3fe12bb 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -4,8 +4,8 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsEvent } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; -import { DomainDecoratedAnalyticsEvent } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; import { ErrorContext } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; @@ -23,14 +23,14 @@ import { StorageValueChange } from '@backstage/core-plugin-api'; export class MockAnalyticsApi implements AnalyticsApi { // (undocumented) captureEvent({ - verb, - noun, + action, + subject, value, + attributes, context, - domain, - }: DomainDecoratedAnalyticsEvent): void; + }: AnalyticsEvent): void; // (undocumented) - getEvents(): DomainDecoratedAnalyticsEvent[]; + getEvents(): AnalyticsEvent[]; } // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts index fdd9478c5d..dad11f985f 100644 --- a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts @@ -17,40 +17,45 @@ import { MockAnalyticsApi } from './MockAnalyticsApi'; describe('MockAnalyticsApi', () => { - const domain = { + const context = { pluginId: 'some-plugin', }; it('should collect events', () => { const api = new MockAnalyticsApi(); - api.captureEvent({ verb: 'verb-1', noun: 'noun-1', domain }); - api.captureEvent({ verb: 'verb-2', noun: 'noun-2', value: 42, domain }); + api.captureEvent({ action: 'action-1', subject: 'subject-1', context }); api.captureEvent({ - verb: 'verb-3', - noun: 'noun-3', + action: 'action-2', + subject: 'subject-2', + value: 42, + context, + }); + api.captureEvent({ + action: 'action-3', + subject: 'subject-3', value: 1337, - context: { some: 'context' }, - domain, + attributes: { some: 'context' }, + context, }); expect(api.getEvents()[0]).toMatchObject({ - noun: 'noun-1', - verb: 'verb-1', - domain, + subject: 'subject-1', + action: 'action-1', + context, }); expect(api.getEvents()[1]).toMatchObject({ - noun: 'noun-2', - verb: 'verb-2', + subject: 'subject-2', + action: 'action-2', value: 42, - domain, + context, }); expect(api.getEvents()[2]).toMatchObject({ - noun: 'noun-3', - verb: 'verb-3', + subject: 'subject-3', + action: 'action-3', value: 1337, - domain, - context: { + 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 index b90290ff6a..28585145ad 100644 --- a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts @@ -14,31 +14,28 @@ * limitations under the License. */ -import { - AnalyticsApi, - DomainDecoratedAnalyticsEvent, -} from '@backstage/core-plugin-api'; +import { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api'; export class MockAnalyticsApi implements AnalyticsApi { - private events: DomainDecoratedAnalyticsEvent[] = []; + private events: AnalyticsEvent[] = []; captureEvent({ - verb, - noun, + action, + subject, value, + attributes, context, - domain, - }: DomainDecoratedAnalyticsEvent) { + }: AnalyticsEvent) { this.events.push({ - verb, - noun, - domain, + action, + subject, + context, ...(value !== undefined ? { value } : {}), - ...(context !== undefined ? { context } : {}), + ...(attributes !== undefined ? { attributes } : {}), }); } - getEvents(): DomainDecoratedAnalyticsEvent[] { + getEvents(): AnalyticsEvent[] { return this.events; } } diff --git a/plugins/analytics-module-ga/README.md b/plugins/analytics-module-ga/README.md index 09bbdeb88b..ac79978676 100644 --- a/plugins/analytics-module-ga/README.md +++ b/plugins/analytics-module-ga/README.md @@ -52,8 +52,8 @@ app: customDimensionsMetrics: - type: dimension index: 1 - source: domain - attribute: pluginId + source: context + key: pluginId ``` You can configure additional custom dimension and metric collection by adding @@ -66,16 +66,16 @@ app: customDimensionsMetrics: - type: dimension index: 1 - source: domain - attribute: pluginId + source: context + key: pluginId - type: dimension index: 2 - source: domain - attribute: routeRef + source: context + key: routeRef - type: metric index: 1 - source: context - attribute: someEventContextAttr + source: attributes + key: someEventContextAttr ``` ### Debugging and Testing @@ -125,8 +125,8 @@ app: customDimensionsMetrics: - type: dimension index: 1 - source: domain - attribute: pluginId + source: context + key: pluginId ``` [configure-custom-dimension]: https://support.google.com/analytics/answer/2709828?hl=en#configuration diff --git a/plugins/analytics-module-ga/config.d.ts b/plugins/analytics-module-ga/config.d.ts index 78772d932a..161776e9dc 100644 --- a/plugins/analytics-module-ga/config.d.ts +++ b/plugins/analytics-module-ga/config.d.ts @@ -45,7 +45,7 @@ export interface Config { testMode?: boolean; /** - * Configuration informing how Analytics Domain and Event Context + * Configuration informing how Analytics Context and Event Attributes * metadata will be captured in Google Analytics. */ customDimensionsMetrics?: Array<{ @@ -67,21 +67,20 @@ export interface Config { /** * Specifies whether the desired value lives as an attribute on the - * Analytics Domain or the Event's Context. + * Analytics Context or the Event's Attributes. * * @visibility frontend */ - source: 'domain' | 'context'; + source: 'context' | 'attributes'; /** - * The attribute on the domain or context that should be captured. + * 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 "domain" and the attribute should be set to - * pluginId. + * should be set to "context" and the key should be set to pluginId. * * @visibility frontend */ - attribute: string; + key: string; }>; }; }; diff --git a/plugins/analytics-module-ga/dev/Playground.tsx b/plugins/analytics-module-ga/dev/Playground.tsx index d2817f5ab4..f2abfbb495 100644 --- a/plugins/analytics-module-ga/dev/Playground.tsx +++ b/plugins/analytics-module-ga/dev/Playground.tsx @@ -15,10 +15,10 @@ */ import React from 'react'; -import { withAnalyticsDomain } from '@backstage/core-plugin-api'; +import { withAnalyticsContext } from '@backstage/core-plugin-api'; import { Link } from '@backstage/core-components'; -const DomainlessPlayground = () => { +const ContextlessPlayground = () => { return ( <> Click Here @@ -26,4 +26,4 @@ const DomainlessPlayground = () => { ); }; -export const Playground = withAnalyticsDomain(DomainlessPlayground, {}); +export const Playground = withAnalyticsContext(ContextlessPlayground, {}); 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 index da319d365c..34ee70a3b8 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -50,7 +50,7 @@ describe('GoogleAnalytics', () => { }); describe('integration', () => { - const domain = { + const context = { componentName: 'App', pluginId: 'some-plugin', releaseNum: 1337, @@ -65,26 +65,26 @@ describe('GoogleAnalytics', () => { { type: 'dimension', index: 1, - source: 'domain', - attribute: 'pluginId', + source: 'context', + key: 'pluginId', }, { type: 'dimension', index: 2, - source: 'context', - attribute: 'extraDimension', + source: 'attributes', + key: 'extraDimension', }, { type: 'metric', index: 1, - source: 'domain', - attribute: 'releaseNum', + source: 'context', + key: 'releaseNum', }, { type: 'metric', index: 2, - source: 'context', - attribute: 'extraMetric', + source: 'attributes', + key: 'extraMetric', }, ], }, @@ -95,9 +95,9 @@ describe('GoogleAnalytics', () => { it('tracks basic pageview', () => { const api = GoogleAnalytics.fromConfig(basicValidConfig); api.captureEvent({ - verb: 'navigate', - noun: '/', - domain, + action: 'navigate', + subject: '/', + context, }); const [command, data] = ReactGA.testModeAPI.calls[1]; @@ -115,17 +115,17 @@ describe('GoogleAnalytics', () => { const expectedLabel = 'on something'; const expectedValue = 42; api.captureEvent({ - verb: expectedAction, - noun: expectedLabel, + action: expectedAction, + subject: expectedLabel, value: expectedValue, - domain, + context, }); const [command, data] = ReactGA.testModeAPI.calls[1]; expect(command).toBe('send'); expect(data).toMatchObject({ hitType: 'event', - eventCategory: domain.componentName, + eventCategory: context.componentName, eventAction: expectedAction, eventLabel: expectedLabel, eventValue: expectedValue, @@ -135,17 +135,17 @@ describe('GoogleAnalytics', () => { it('captures configured custom dimensions/metrics on pageviews', () => { const api = GoogleAnalytics.fromConfig(advancedConfig); api.captureEvent({ - verb: 'navigate', - noun: '/a-page', - domain, + 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: domain.pluginId, - metric1: domain.releaseNum, + dimension1: context.pluginId, + metric1: context.releaseNum, }); // Followed by a send command. @@ -164,26 +164,26 @@ describe('GoogleAnalytics', () => { const expectedLabel = 'some query'; const expectedValue = 5; api.captureEvent({ - verb: expectedAction, - noun: expectedLabel, + action: expectedAction, + subject: expectedLabel, value: expectedValue, - context: { + attributes: { extraDimension: false, extraMetric: 0, }, - domain, + context, }); const [command, data] = ReactGA.testModeAPI.calls[1]; expect(command).toBe('send'); expect(data).toMatchObject({ hitType: 'event', - eventCategory: domain.componentName, + eventCategory: context.componentName, eventAction: expectedAction, eventLabel: expectedLabel, eventValue: expectedValue, - dimension1: domain.pluginId, - metric1: domain.releaseNum, + dimension1: context.pluginId, + metric1: context.releaseNum, dimension2: false, metric2: 0, }); @@ -193,12 +193,12 @@ describe('GoogleAnalytics', () => { const api = GoogleAnalytics.fromConfig(advancedConfig); api.captureEvent({ - verb: 'verb', - noun: 'noun', - context: { + action: 'verb', + subject: 'noun', + attributes: { extraMetric: 'not a number', }, - domain, + context, }); const [, data] = ReactGA.testModeAPI.calls[1]; diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index e721dc5e01..dceaf7b2d6 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -17,17 +17,17 @@ import ReactGA from 'react-ga'; import { AnalyticsApi, - AnalyticsDomainValue, - AnalyticsEventContext, - DomainDecoratedAnalyticsEvent, + AnalyticsContextValue, + AnalyticsEventAttributes, + AnalyticsEvent, } from '@backstage/core-plugin-api'; import { Config } from '@backstage/config'; type CDM = { type: 'dimension' | 'metric'; index: number; - source: 'domain' | 'context'; - attribute: string; + source: 'context' | 'attributes'; + key: string; }; /** @@ -79,7 +79,7 @@ export class GoogleAnalytics implements AnalyticsApi { type: c.getString('type') as CDM['type'], index: c.getNumber('index'), source: c.getString('source') as CDM['source'], - attribute: c.getString('attribute'), + key: c.getString('key'), }; }) || []; @@ -98,47 +98,45 @@ export class GoogleAnalytics implements AnalyticsApi { * applied as they should be (set on pageview, merged object on events). */ captureEvent({ - domain, - verb, - noun, - value, context, - }: DomainDecoratedAnalyticsEvent) { - const customMetadata = this.getCustomDimensionMetrics(domain, context); + action, + subject, + value, + attributes, + }: AnalyticsEvent) { + const customMetadata = this.getCustomDimensionMetrics(context, attributes); - if (verb === 'navigate' && domain?.componentName === 'App') { + if (action === 'navigate' && context?.componentName === 'App') { // Set any/all custom dimensions. if (Object.keys(customMetadata).length) { ReactGA.set(customMetadata); } - ReactGA.pageview(noun); + ReactGA.pageview(subject); return; } ReactGA.event({ - category: domain.componentName || 'App', - action: verb, - label: noun, + category: context.componentName || 'App', + action, + label: subject, value, ...customMetadata, }); } /** - * Returns an object of dimensions/metrics given an Analytics Domain and an + * Returns an object of dimensions/metrics given an Analytics Context and an * Event Context, e.g. { dimension1: "some value", metric8: 42 } */ private getCustomDimensionMetrics( - domain: AnalyticsDomainValue, - context: AnalyticsEventContext = {}, + context: AnalyticsContextValue, + attributes: AnalyticsEventAttributes = {}, ) { const dataArray = this.cdmConfig .map(cdm => { const value = - cdm.source === 'domain' - ? domain[cdm.attribute] - : context[cdm.attribute]; + cdm.source === 'context' ? context[cdm.key] : attributes[cdm.key]; // Never pass a non-numeric value on a metric. if (cdm.type === 'metric' && typeof value !== 'number') { From 11582dacc2b77bcce3121ad90dbdefd6802c5023 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 16 Sep 2021 15:31:34 +0200 Subject: [PATCH 15/25] Stabilize the tracker reference to avoid duplicate events. Signed-off-by: Eric Peterson --- .../core-app-api/src/routing/RouteTracker.tsx | 3 +- .../core-plugin-api/src/analytics/Tracker.ts | 51 +++++++++++++++++++ .../src/analytics/useAnalytics.tsx | 27 +++------- 3 files changed, 58 insertions(+), 23 deletions(-) create mode 100644 packages/core-plugin-api/src/analytics/Tracker.ts diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index 8ca76be78e..5c0cd56862 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -99,8 +99,7 @@ const TrackNavigation = ({ useEffect(() => { analytics.captureEvent('navigate', `${pathname}${search}${hash}`); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pathname, search, hash]); + }, [analytics, pathname, search, hash]); return null; }; 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..d1ddf5d342 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/Tracker.ts @@ -0,0 +1,51 @@ +/* + * 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 } from '../apis'; +import { AnalyticsContextValue } from './'; + +export class Tracker { + constructor( + private readonly analyticsApi: AnalyticsApi, + private context: AnalyticsContextValue = {}, + ) {} + + 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/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx index 8b104e347c..dd1176fff0 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -15,31 +15,16 @@ */ import { useAnalyticsContext } from './AnalyticsContext'; -import { - analyticsApiRef, - AnalyticsTracker, -} from '../apis/definitions/AnalyticsApi'; -import { useApi } from '../apis'; +import { analyticsApiRef, AnalyticsTracker, useApi } from '../apis'; +import { useRef } from 'react'; +import { Tracker } from './Tracker'; function useTracker(): AnalyticsTracker { const analyticsApi = useApi(analyticsApiRef); + const tracker = useRef(new Tracker(analyticsApi)); const context = useAnalyticsContext(); - return { - captureEvent: (action, subject, { value, attributes } = {}) => { - try { - analyticsApi.captureEvent({ - action, - subject, - value, - attributes, - context, - }); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('Error during analytics event capture. %o', e); - } - }, - }; + tracker.current.setContext(context); + return tracker.current; } /** From ae4680b88d878c48317e8601af09cc60b47afd14 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 16 Sep 2021 20:15:28 +0200 Subject: [PATCH 16/25] Make componetName == extension (name) Signed-off-by: Eric Peterson --- .../analytics-glimmering-radio-station.md | 6 ++++ ...ytics-haunts-dismembered-constellations.md | 2 +- .changeset/analytics-sings-stormy-weather.md | 2 +- .../default-plugin/src/plugin.ts.hbs | 1 + packages/core-app-api/src/app/App.test.tsx | 4 +-- .../core-app-api/src/routing/RouteTracker.tsx | 2 +- .../src/components/Link/Link.test.tsx | 1 - .../src/components/Link/Link.tsx | 21 ++++-------- packages/core-plugin-api/api-report.md | 13 +------- .../src/analytics/AnalyticsContext.test.tsx | 33 ++++--------------- .../src/analytics/AnalyticsContext.tsx | 2 +- .../core-plugin-api/src/analytics/index.ts | 2 +- .../core-plugin-api/src/analytics/types.ts | 4 +-- .../src/analytics/useAnalytics.test.tsx | 2 +- .../src/extensions/extensions.test.tsx | 6 ++-- .../src/extensions/extensions.tsx | 2 +- .../analytics-module-ga/dev/Playground.tsx | 5 +-- .../AnalyticsApi/GoogleAnalytics.test.ts | 6 ++-- .../AnalyticsApi/GoogleAnalytics.ts | 4 +-- 19 files changed, 41 insertions(+), 77 deletions(-) create mode 100644 .changeset/analytics-glimmering-radio-station.md 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 index 6acf7d8bca..851527d852 100644 --- a/.changeset/analytics-haunts-dismembered-constellations.md +++ b/.changeset/analytics-haunts-dismembered-constellations.md @@ -12,7 +12,7 @@ metadata, allowing clicks to be attributed to the plugin containing the link: "action": "click", "subject": "/value/of-the/to-prop/passed-to-the-link", "context": { - "componentName": "SomeAssociatedExtension", + "extension": "SomeAssociatedExtension", "pluginId": "plugin-in-which-link-was-clicked", "routeRef": "any-associated-route-ref-id" } diff --git a/.changeset/analytics-sings-stormy-weather.md b/.changeset/analytics-sings-stormy-weather.md index a04e824b60..4b5eb1f237 100644 --- a/.changeset/analytics-sings-stormy-weather.md +++ b/.changeset/analytics-sings-stormy-weather.md @@ -13,7 +13,7 @@ analytics context, which can be useful for analyzing plugin usage: "action": "navigate", "subject": "/the-path/navigated/to?with=params#and-hashes", "context": { - "componentName": "App", + "extension": "App", "pluginId": "id-of-plugin-that-exported-the-route", "routeRef": "associated-route-ref-id" } 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/src/app/App.test.tsx b/packages/core-app-api/src/app/App.test.tsx index b04cf3edbf..cd323fdb6e 100644 --- a/packages/core-app-api/src/app/App.test.tsx +++ b/packages/core-app-api/src/app/App.test.tsx @@ -383,7 +383,7 @@ describe('Integration Test', () => { action: 'navigate', subject: '/', context: { - componentName: 'App', + extension: 'App', pluginId: 'blob', routeRef: 'ref-1-2', }, @@ -392,7 +392,7 @@ describe('Integration Test', () => { action: 'navigate', subject: '/foo', context: { - componentName: 'App', + extension: 'App', pluginId: 'plugin2', routeRef: 'ref-2', }, diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index 5c0cd56862..673772a605 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -67,7 +67,7 @@ const getExtensionContext = ( if (plugin && mountPoint) { return { pluginId: plugin.getId(), - componentName: 'App', + extension: 'App', routeRef: mountPoint?.id || '', }; } diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index e2e8612fc9..9690bdfcba 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -65,7 +65,6 @@ describe('', () => { expect(analyticsApi.getEvents()[0]).toMatchObject({ action: 'click', subject: '/test', - context: { componentName: 'Link' }, }); // Custom onClick handler should have still been fired too. diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index 1134bc1b07..69da5cd613 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -14,19 +14,17 @@ * limitations under the License. */ -import { useAnalytics, withAnalyticsContext } from '@backstage/core-plugin-api'; +import { useAnalytics } from '@backstage/core-plugin-api'; import { Link as MaterialLink, LinkProps as MaterialLinkProps, } from '@material-ui/core'; -import React, { ElementType, MutableRefObject } from 'react'; +import React, { ElementType } from 'react'; import { Link as RouterLink, LinkProps as RouterLinkProps, } from 'react-router-dom'; -type OptionalRef = MutableRefObject | ((instance: any) => void) | null; - export const isExternalUri = (uri: string) => /^([a-z+.-]+):/.test(uri); export type LinkProps = MaterialLinkProps & @@ -41,8 +39,8 @@ declare function LinkType(props: LinkProps): JSX.Element; * - Makes the Link use react-router * - Captures Link clicks as analytics events. */ -const ActualLink = withAnalyticsContext( - ({ inputRef, onClick, ...props }: LinkProps & { inputRef: OptionalRef }) => { +const ActualLink = React.forwardRef( + ({ onClick, ...props }, ref) => { const analytics = useAnalytics(); const to = String(props.to); const external = isExternalUri(to); @@ -58,7 +56,7 @@ const ActualLink = withAnalyticsContext( return external ? ( // External links ); }, - { componentName: 'Link' }, ); -export const WrappedLink = React.forwardRef((props, ref) => ( - -)); - // TODO(Rugvip): We use this as a workaround to make the exported type be a // function, which makes our API reference docs much nicer. // The first type to be exported gets priority, but it will // be thrown away when compiling to JS. // @ts-ignore -export { LinkType as Link, WrappedLink as Link }; +export { LinkType as Link, ActualLink as Link }; diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index d531244cd7..76411f3f02 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -303,7 +303,7 @@ export type BootErrorPageProps = { export type CommonAnalyticsContext = { pluginId: string; routeRef: string; - componentName: 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) @@ -964,17 +964,6 @@ export function useRouteRefParams( _routeRef: RouteRef | SubRouteRef, ): Params; -// Warning: (ae-missing-release-tag) "withAnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export function withAnalyticsContext

( - Component: React_2.ComponentType

, - values: AnalyticsContextValue, -): { - (props: P): JSX.Element; - displayName: string; -}; - // Warning: (ae-missing-release-tag) "withApis" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx index cad6d5ec38..88e8afa6ef 100644 --- a/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx @@ -17,11 +17,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; -import { - AnalyticsContext, - useAnalyticsContext, - withAnalyticsContext, -} from './AnalyticsContext'; +import { AnalyticsContext, useAnalyticsContext } from './AnalyticsContext'; const AnalyticsSpy = () => { const context = useAnalyticsContext(); @@ -29,7 +25,7 @@ const AnalyticsSpy = () => { <>

{context.routeRef}
{context.pluginId}
-
{context.componentName}
+
{context.extension}
{context.custom}
); @@ -40,7 +36,7 @@ describe('AnalyticsContext', () => { it('returns default values', () => { const { result } = renderHook(() => useAnalyticsContext()); expect(result.current).toEqual({ - componentName: 'App', + extension: 'App', pluginId: 'root', routeRef: 'unknown', }); @@ -55,7 +51,7 @@ describe('AnalyticsContext', () => { , ); - expect(result.getByTestId('component-name')).toHaveTextContent('App'); + expect(result.getByTestId('extension')).toHaveTextContent('App'); expect(result.getByTestId('plugin-id')).toHaveTextContent('root'); expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); }); @@ -67,7 +63,7 @@ describe('AnalyticsContext', () => { , ); - expect(result.getByTestId('component-name')).toHaveTextContent('App'); + expect(result.getByTestId('extension')).toHaveTextContent('App'); expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); }); @@ -75,30 +71,15 @@ describe('AnalyticsContext', () => { it('uses nested analytics context', () => { const result = render( - + , ); - expect(result.getByTestId('component-name')).toHaveTextContent( - 'AnalyticsSpy', - ); + expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy'); expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); }); }); - - describe('withAnalyticsContext', () => { - it('wraps component with analytics context', () => { - const AnalyticsSpyHOC = withAnalyticsContext(AnalyticsSpy, { - custom: 'attr', - }); - const result = render(); - expect(result.getByTestId('custom')).toHaveTextContent('attr'); - expect(AnalyticsSpyHOC.displayName).toBe( - 'WithAnalyticsContext(AnalyticsSpy)', - ); - }); - }); }); diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx index f50375d42f..5852dab51c 100644 --- a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx @@ -20,7 +20,7 @@ import { AnalyticsContextValue } from './types'; const AnalyticsReactContext = createContext({ routeRef: 'unknown', pluginId: 'root', - componentName: 'App', + extension: 'App', }); /** diff --git a/packages/core-plugin-api/src/analytics/index.ts b/packages/core-plugin-api/src/analytics/index.ts index dbf82aa4e3..651e8e105e 100644 --- a/packages/core-plugin-api/src/analytics/index.ts +++ b/packages/core-plugin-api/src/analytics/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export { AnalyticsContext, withAnalyticsContext } from './AnalyticsContext'; +export { AnalyticsContext } from './AnalyticsContext'; export type { AnalyticsContextValue, 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 index 189d182328..045328f613 100644 --- a/packages/core-plugin-api/src/analytics/types.ts +++ b/packages/core-plugin-api/src/analytics/types.ts @@ -29,9 +29,9 @@ export type CommonAnalyticsContext = { routeRef: string; /** - * The name of the associated component. + * The name of the associated extension. */ - componentName: string; + extension: string; }; /** diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx index ce27602e6c..22021026af 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx @@ -55,7 +55,7 @@ describe('useAnalytics', () => { some: 'value', }, context: { - componentName: 'App', + extension: 'App', pluginId: 'root', routeRef: 'unknown', }, diff --git a/packages/core-plugin-api/src/extensions/extensions.test.tsx b/packages/core-plugin-api/src/extensions/extensions.test.tsx index 42a2bf5e66..eff8db5029 100644 --- a/packages/core-plugin-api/src/extensions/extensions.test.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.test.tsx @@ -121,7 +121,7 @@ describe('extensions', () => { <>
{context.pluginId}
{context.routeRef}
-
{context.componentName}
+
{context.extension}
); }, @@ -134,8 +134,6 @@ describe('extensions', () => { expect(result.getByTestId('plugin-id')).toHaveTextContent('my-plugin'); expect(result.getByTestId('route-ref')).toHaveTextContent('some-ref'); - expect(result.getByTestId('component-name')).toHaveTextContent( - 'AnalyticsSpy', - ); + 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 7aa16f5692..31bb08a13f 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -140,7 +140,7 @@ export function createReactExtension< { +export const Playground = () => { return ( <> Click Here ); }; - -export const Playground = withAnalyticsContext(ContextlessPlayground, {}); 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 index 34ee70a3b8..c262849695 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -51,7 +51,7 @@ describe('GoogleAnalytics', () => { describe('integration', () => { const context = { - componentName: 'App', + extension: 'App', pluginId: 'some-plugin', releaseNum: 1337, }; @@ -125,7 +125,7 @@ describe('GoogleAnalytics', () => { expect(command).toBe('send'); expect(data).toMatchObject({ hitType: 'event', - eventCategory: context.componentName, + eventCategory: context.extension, eventAction: expectedAction, eventLabel: expectedLabel, eventValue: expectedValue, @@ -178,7 +178,7 @@ describe('GoogleAnalytics', () => { expect(command).toBe('send'); expect(data).toMatchObject({ hitType: 'event', - eventCategory: context.componentName, + eventCategory: context.extension, eventAction: expectedAction, eventLabel: expectedLabel, eventValue: expectedValue, diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index dceaf7b2d6..a05b4fe127 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -106,7 +106,7 @@ export class GoogleAnalytics implements AnalyticsApi { }: AnalyticsEvent) { const customMetadata = this.getCustomDimensionMetrics(context, attributes); - if (action === 'navigate' && context?.componentName === 'App') { + if (action === 'navigate' && context?.extension === 'App') { // Set any/all custom dimensions. if (Object.keys(customMetadata).length) { ReactGA.set(customMetadata); @@ -117,7 +117,7 @@ export class GoogleAnalytics implements AnalyticsApi { } ReactGA.event({ - category: context.componentName || 'App', + category: context.extension || 'App', action, label: subject, value, From 0bf2112f9da9313c0b2afa6aada842950249f309 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 16 Sep 2021 21:58:32 +0200 Subject: [PATCH 17/25] Add plugin metadata to route object to simplify navigation tracking. Signed-off-by: Eric Peterson --- packages/core-app-api/src/app/App.tsx | 6 +- .../core-app-api/src/routing/RouteTracker.tsx | 91 ++++++++----------- .../src/routing/collectors.test.tsx | 5 +- .../core-app-api/src/routing/collectors.tsx | 10 +- packages/core-app-api/src/routing/types.ts | 2 + 5 files changed, 58 insertions(+), 56 deletions(-) diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx index fb40a83dab..8cb3f167fb 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/App.tsx @@ -23,7 +23,7 @@ import React, { useMemo, useState, } from 'react'; -import { createRoutesFromChildren, Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router-dom'; import { useAsync } from 'react-use'; import { ApiProvider, @@ -368,7 +368,7 @@ export class PrivateAppImpl implements BackstageApp { return ( - + {children}} /> @@ -378,7 +378,7 @@ export class PrivateAppImpl implements BackstageApp { return ( - + {children}} /> diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index 673772a605..f53a6fa1e8 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -14,70 +14,51 @@ * limitations under the License. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; +import { matchRoutes, useLocation } from 'react-router-dom'; import { - createRoutesFromChildren, - matchRoutes, - useLocation, -} from 'react-router-dom'; -import { - BackstagePlugin, useAnalytics, - getComponentData, AnalyticsContext, CommonAnalyticsContext, + RouteRef, } from '@backstage/core-plugin-api'; - -type RouteObjects = ReturnType; +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 RouteObject - * that defines all registered routes in react. - * - * If no exact match is found, path parts are stripped away, one-by-one, until - * a parent-level path matches a route. + * Returns an extension context given the current pathname and a list of + * Backstage route objects. */ const getExtensionContext = ( pathname: string, - routes: RouteObjects, + routes: BackstageRouteObject[], ): CommonAnalyticsContext | {} => { try { - const cleanPath = pathname.replace(/\/+$/, ''); const matches = matchRoutes(routes, { pathname }); - const RouteElement = matches - ?.filter(match => { - const pathsMatch = match.pathname.replace(/\/+$/, '') === cleanPath; - const hasRoutableElement = !!(match.route.element as React.ReactElement) - ?.props?.element; - return pathsMatch && hasRoutableElement; - }) - .pop()?.route?.element; - const RoutableElement = (RouteElement as React.ReactElement)?.props - ?.element; + const routeObject = matches + ?.filter( + match => (match?.route as BackstageRouteObject).routeRefs?.size > 0, + ) + .pop()?.route as BackstageRouteObject; - if (RoutableElement) { - const plugin: BackstagePlugin | undefined = getComponentData( - RoutableElement, - 'core.plugin', - ); - const mountPoint: { id?: string } | undefined = getComponentData( - RoutableElement, - 'core.mountPoint', - ); - if (plugin && mountPoint) { - return { - pluginId: plugin.getId(), - extension: 'App', - routeRef: mountPoint?.id || '', - }; - } + if (!routeObject) { + return {}; } - // Try again, one path-level shallower. - const nextLevelPath = cleanPath.split('/').slice(0, -1).join('/'); - return nextLevelPath !== '' - ? getExtensionContext(nextLevelPath, routes) - : {}; + const routeRef = routeObject.routeRefs.values().next().value as + | RouteRef + | undefined; + + return { + extension: 'App', + pluginId: routeObject.plugin?.getId() || 'root', + routeRef: (routeRef as { id?: string }).id, + }; } catch { return {}; } @@ -108,12 +89,20 @@ const TrackNavigation = ({ * Logs a "navigate" event with appropriate plugin-level analytics context * attributes each time the user navigates to a page. */ -export const RouteTracker = ({ objects }: { objects: RouteObjects }) => { +export const RouteTracker = ({ tree }: { tree: React.ReactNode }) => { const { pathname, search, hash } = useLocation(); - const attributes = getExtensionContext(pathname, objects); + 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..2d044b4b23 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; 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( From b9e3a03f9c0a6bb824cd0ac32b76a0fa78f9b820 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 16 Sep 2021 22:32:10 +0200 Subject: [PATCH 18/25] More standard installation method/instructions. Signed-off-by: Eric Peterson --- plugins/analytics-module-ga/README.md | 22 +++++++++++++++++----- plugins/analytics-module-ga/api-report.md | 17 +++++++++++++++++ plugins/analytics-module-ga/src/index.ts | 2 ++ plugins/analytics-module-ga/src/plugin.ts | 15 +-------------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/plugins/analytics-module-ga/README.md b/plugins/analytics-module-ga/README.md index ac79978676..a7e681bbe5 100644 --- a/plugins/analytics-module-ga/README.md +++ b/plugins/analytics-module-ga/README.md @@ -4,16 +4,28 @@ 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 (and will never contain any) other functionality. +This plugin contains no other functionality. ## Installation 1. Install this plugin in your Backstage app: `cd packages/app && yarn add @backstage/plugin-analytics-module-ga` -2. Register the plugin with your App. In most instances of Backstage, this is - as simple as adding `export { analyticsModuleGA } from '@backstage/plugin-analytics-module-ga';` - to your `packages/app/src/plugins.ts` file. -3. Configure the plugin (see below). +2. Add the API implementation to your App and configure the plugin (see below). + +```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), + }), +]; +``` ## Configuration diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md index 6208ebe795..7ca3b1646f 100644 --- a/plugins/analytics-module-ga/api-report.md +++ b/plugins/analytics-module-ga/api-report.md @@ -3,12 +3,29 @@ > 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/src/index.ts b/plugins/analytics-module-ga/src/index.ts index d9d3222ac0..17a3213c6d 100644 --- a/plugins/analytics-module-ga/src/index.ts +++ b/plugins/analytics-module-ga/src/index.ts @@ -13,4 +13,6 @@ * 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.ts b/plugins/analytics-module-ga/src/plugin.ts index e339965d91..d63ae6616f 100644 --- a/plugins/analytics-module-ga/src/plugin.ts +++ b/plugins/analytics-module-ga/src/plugin.ts @@ -13,21 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - analyticsApiRef, - configApiRef, - createApiFactory, - createPlugin, -} from '@backstage/core-plugin-api'; -import { GoogleAnalytics } from './apis/implementations/AnalyticsApi'; +import { createPlugin } from '@backstage/core-plugin-api'; export const analyticsModuleGA = createPlugin({ id: 'analytics-provider-ga', - apis: [ - createApiFactory({ - api: analyticsApiRef, - deps: { config: configApiRef }, - factory: ({ config }) => GoogleAnalytics.fromConfig(config), - }), - ], }); From 2debe758f11c59744bbc60fa42af4284ee685ff2 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 23 Sep 2021 12:09:54 +0200 Subject: [PATCH 19/25] Fix plugin handling on gathered mount points and document logic. Signed-off-by: Eric Peterson --- .../core-app-api/src/routing/RouteTracker.tsx | 16 ++++++++++++---- packages/core-app-api/src/routing/collectors.tsx | 1 + 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index f53a6fa1e8..f65ef49dec 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -39,25 +39,33 @@ const getExtensionContext = ( routes: BackstageRouteObject[], ): CommonAnalyticsContext | {} => { try { + // Find matching routes for the given path name. const matches = matchRoutes(routes, { pathname }); + + // Of the matching routes, get the last (e.g. most specific) instance of + // the BackstageRouteObject. const routeObject = matches ?.filter( match => (match?.route as BackstageRouteObject).routeRefs?.size > 0, ) .pop()?.route as BackstageRouteObject; + // If there is no route object, then allow inheritance of default context. if (!routeObject) { return {}; } - const routeRef = routeObject.routeRefs.values().next().value as - | RouteRef - | undefined; + // 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 as { id?: string }).id, + ...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}), }; } catch { return {}; diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx index 2d044b4b23..320f1a66d8 100644 --- a/packages/core-app-api/src/routing/collectors.tsx +++ b/packages/core-app-api/src/routing/collectors.tsx @@ -171,6 +171,7 @@ export const routeObjectCollector = createCollector( element: 'gathered', routeRefs: new Set(), children: [MATCH_ALL_ROUTE], + plugin: parentObj?.plugin, }; parentChildren.push(newObject); return newObject; From 3f1bfab314ae95fda2ba0636ab843496ec334d9e Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 26 Sep 2021 15:40:44 +0200 Subject: [PATCH 20/25] Bump new package dependencies. Signed-off-by: Eric Peterson --- plugins/analytics-module-ga/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 1618129d90..d50306d0a4 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.4.2", + "@backstage/core-components": "^0.5.0", "@backstage/core-plugin-api": "^0.1.8", "@backstage/theme": "^0.2.10", "@material-ui/core": "^4.12.2", @@ -33,9 +33,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.12", - "@backstage/core-app-api": "^0.1.13", - "@backstage/dev-utils": "^0.2.9", + "@backstage/cli": "^0.7.13", + "@backstage/core-app-api": "^0.1.14", + "@backstage/dev-utils": "^0.2.10", "@backstage/test-utils": "^0.1.17", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", From b500fac551de070cb215b0366e7a93d7ef862e61 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 26 Sep 2021 18:31:22 +0200 Subject: [PATCH 21/25] Review feedback. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Camila Belo Co-authored-by: Fredrik Adelöw Signed-off-by: Eric Peterson --- .../core-app-api/src/routing/RouteTracker.tsx | 10 +-- .../src/components/Link/Link.test.tsx | 27 ++++---- .../src/components/Link/Link.tsx | 4 +- packages/core-plugin-api/api-report.md | 15 ++-- .../src/analytics/AnalyticsContext.tsx | 15 ++-- .../core-plugin-api/src/analytics/Tracker.ts | 14 +++- .../core-plugin-api/src/analytics/index.ts | 6 +- .../core-plugin-api/src/analytics/types.ts | 13 ++-- .../src/analytics/useAnalytics.tsx | 16 ++++- .../src/extensions/extensions.test.tsx | 2 +- .../src/extensions/extensions.tsx | 12 ++-- .../AnalyticsApi/MockAnalyticsApi.test.ts | 2 + plugins/analytics-module-ga/README.md | 22 ++++-- plugins/analytics-module-ga/package.json | 1 + .../AnalyticsApi/GoogleAnalytics.test.ts | 1 + .../AnalyticsApi/GoogleAnalytics.ts | 68 +++++++++---------- 16 files changed, 129 insertions(+), 99 deletions(-) diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx index f65ef49dec..464f451c9c 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -40,15 +40,15 @@ const getExtensionContext = ( ): CommonAnalyticsContext | {} => { try { // Find matching routes for the given path name. - const matches = matchRoutes(routes, { pathname }); + 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 as BackstageRouteObject).routeRefs?.size > 0, - ) - .pop()?.route as BackstageRouteObject; + ?.filter(match => match?.route.routeRefs?.size > 0) + .pop()?.route; // If there is no route object, then allow inheritance of default context. if (!routeObject) { diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index 9690bdfcba..071b6fcffe 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -15,13 +15,12 @@ */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +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 () => { @@ -36,10 +35,10 @@ describe('', () => { ), ); expect(() => getByText(testString)).toThrow(); - await act(async () => { - fireEvent.click(getByText(linkText)); + fireEvent.click(getByText(linkText)); + await waitFor(() => { + expect(getByText(testString)).toBeInTheDocument(); }); - expect(getByText(testString)).toBeInTheDocument(); }); it('captures click using analytics api', async () => { @@ -57,18 +56,18 @@ describe('', () => { ), ); - await act(async () => { - fireEvent.click(getByText(linkText)); - }); + fireEvent.click(getByText(linkText)); // Analytics event should have been fired. - expect(analyticsApi.getEvents()[0]).toMatchObject({ - action: 'click', - subject: '/test', - }); + await waitFor(() => { + expect(analyticsApi.getEvents()[0]).toMatchObject({ + action: 'click', + subject: '/test', + }); - // Custom onClick handler should have still been fired too. - expect(customOnClick).toHaveBeenCalled(); + // Custom onClick handler should have still been fired too. + expect(customOnClick).toHaveBeenCalled(); + }); }); describe('isExternalUri', () => { diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index 69da5cd613..fcfd2483de 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -47,9 +47,7 @@ const ActualLink = React.forwardRef( const newWindow = external && !!/^https?:/.exec(to); const handleClick = (event: React.MouseEvent) => { - if (onClick !== undefined) { - onClick(event); - } + onClick?.(event); analytics.captureEvent('click', to); }; diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 76411f3f02..48ac4f7156 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -53,17 +53,15 @@ export const AnalyticsContext: ({ attributes, children, }: { - attributes: AnalyticsContextValue; + attributes: Partial; children: ReactNode; }) => JSX.Element; -// Warning: (ae-forgotten-export) The symbol "AnyAnalyticsContext" needs to be exported by the entry point index.d.ts // 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 = Partial< - CommonAnalyticsContext & AnyAnalyticsContext ->; +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) // @@ -97,6 +95,13 @@ export type AnalyticsTracker = { ) => 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) diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx index 5852dab51c..284b4186b2 100644 --- a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { createContext, ReactNode, useContext } from 'react'; +import React, { createContext, ReactNode, useContext, useMemo } from 'react'; import { AnalyticsContextValue } from './types'; const AnalyticsReactContext = createContext({ @@ -43,14 +43,17 @@ export const AnalyticsContext = ({ attributes, children, }: { - attributes: AnalyticsContextValue; + attributes: Partial; children: ReactNode; }) => { const parentValues = useAnalyticsContext(); - const combinedValue = { - ...parentValues, - ...attributes, - }; + const combinedValue = useMemo( + () => ({ + ...parentValues, + ...attributes, + }), + [parentValues, attributes], + ); return ( diff --git a/packages/core-plugin-api/src/analytics/Tracker.ts b/packages/core-plugin-api/src/analytics/Tracker.ts index d1ddf5d342..51e99bff14 100644 --- a/packages/core-plugin-api/src/analytics/Tracker.ts +++ b/packages/core-plugin-api/src/analytics/Tracker.ts @@ -14,13 +14,21 @@ * limitations under the License. */ -import { AnalyticsApi, AnalyticsEventAttributes } from '../apis'; +import { + AnalyticsApi, + AnalyticsEventAttributes, + AnalyticsTracker, +} from '../apis'; import { AnalyticsContextValue } from './'; -export class Tracker { +export class Tracker implements AnalyticsTracker { constructor( private readonly analyticsApi: AnalyticsApi, - private context: AnalyticsContextValue = {}, + private context: AnalyticsContextValue = { + routeRef: 'unknown', + pluginId: 'root', + extension: 'App', + }, ) {} setContext(context: AnalyticsContextValue) { diff --git a/packages/core-plugin-api/src/analytics/index.ts b/packages/core-plugin-api/src/analytics/index.ts index 651e8e105e..942df56ef8 100644 --- a/packages/core-plugin-api/src/analytics/index.ts +++ b/packages/core-plugin-api/src/analytics/index.ts @@ -15,5 +15,9 @@ */ export { AnalyticsContext } from './AnalyticsContext'; -export type { AnalyticsContextValue, CommonAnalyticsContext } from './types'; +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 index 045328f613..ea6c3b030b 100644 --- a/packages/core-plugin-api/src/analytics/types.ts +++ b/packages/core-plugin-api/src/analytics/types.ts @@ -19,17 +19,17 @@ */ export type CommonAnalyticsContext = { /** - * The associated plugin. + * The nearest known parent plugin where the event was captured. */ pluginId: string; /** - * The ID of the associated route ref. + * The ID of the routeRef that was active when the event was captured. */ routeRef: string; /** - * The name of the associated extension. + * The nearest known parent extension where the event was captured. */ extension: string; }; @@ -37,13 +37,12 @@ export type CommonAnalyticsContext = { /** * Allow arbitrary scalar values as context attributes too. */ -type AnyAnalyticsContext = { +export type AnyAnalyticsContext = { [param in string]: string | boolean | number | undefined; }; /** * Analytics context envelope. */ -export type AnalyticsContextValue = Partial< - CommonAnalyticsContext & AnyAnalyticsContext ->; +export type AnalyticsContextValue = CommonAnalyticsContext & + AnyAnalyticsContext; diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx index dd1176fff0..2dcb284879 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -20,11 +20,21 @@ import { useRef } from 'react'; import { Tracker } from './Tracker'; function useTracker(): AnalyticsTracker { + const trackerRef = useRef(null); const analyticsApi = useApi(analyticsApiRef); - const tracker = useRef(new Tracker(analyticsApi)); const context = useAnalyticsContext(); - tracker.current.setContext(context); - return tracker.current; + + 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/extensions/extensions.test.tsx b/packages/core-plugin-api/src/extensions/extensions.test.tsx index eff8db5029..06b77c248c 100644 --- a/packages/core-plugin-api/src/extensions/extensions.test.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.test.tsx @@ -126,7 +126,7 @@ describe('extensions', () => { ); }, }, - data: { 'core.mountpoint': { id: 'some-ref' } }, + data: { 'core.mountPoint': { id: 'some-ref' } }, }), ); diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index 31bb08a13f..5427b5f61c 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -133,6 +133,9 @@ export function createReactExtension< const Result: any = (props: any) => { const app = useApp(); const { Progress } = app.getComponents(); + const mountPoint = data?.['core.mountPoint'] as + | { id?: string } + | undefined; return ( }> @@ -140,13 +143,8 @@ export function createReactExtension< diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts index dad11f985f..3593774a01 100644 --- a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts @@ -19,6 +19,8 @@ import { MockAnalyticsApi } from './MockAnalyticsApi'; describe('MockAnalyticsApi', () => { const context = { pluginId: 'some-plugin', + routeRef: 'some-route-ref', + extension: 'some-extension', }; it('should collect events', () => { diff --git a/plugins/analytics-module-ga/README.md b/plugins/analytics-module-ga/README.md index a7e681bbe5..15ad4f1948 100644 --- a/plugins/analytics-module-ga/README.md +++ b/plugins/analytics-module-ga/README.md @@ -8,9 +8,9 @@ This plugin contains no other functionality. ## Installation -1. Install this plugin in your Backstage app: +1. Install the plugin package in your Backstage app: `cd packages/app && yarn add @backstage/plugin-analytics-module-ga` -2. Add the API implementation to your App and configure the plugin (see below). +2. Wire up the API implementation to your App: ```tsx // packages/app/src/apis.ts @@ -27,7 +27,7 @@ export const apis: AnyApiFactory[] = [ ]; ``` -## Configuration +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: @@ -41,11 +41,11 @@ app: trackingId: UA-0000000-0 ``` -### Plugin Analytics +## Configuration In order to be able to analyze usage of your Backstage instance _by plugin_, we -strongly recommend configuring at least one custom dimension to capture Plugin -IDs associated with events, including page views. +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 @@ -84,6 +84,10 @@ app: index: 2 source: context key: routeRef + - type: dimension + index: 3 + source: context + key: extension - type: metric index: 1 source: attributes @@ -104,6 +108,9 @@ app: 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 @@ -141,4 +148,5 @@ app: key: pluginId ``` -[configure-custom-dimension]: https://support.google.com/analytics/answer/2709828?hl=en#configuration +[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/package.json b/plugins/analytics-module-ga/package.json index d50306d0a4..71168f819c 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -4,6 +4,7 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", + "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", 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 index c262849695..cbcf95cb55 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -53,6 +53,7 @@ describe('GoogleAnalytics', () => { const context = { extension: 'App', pluginId: 'some-plugin', + routeRef: 'unknown', releaseNum: 1337, }; const advancedConfig = new ConfigReader({ diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index a05b4fe127..1e6216936c 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -23,25 +23,18 @@ import { } from '@backstage/core-plugin-api'; import { Config } from '@backstage/config'; -type CDM = { +type CustomDimensionOrMetricConfig = { type: 'dimension' | 'metric'; index: number; source: 'context' | 'attributes'; key: string; }; -/** - * Type guard for emptiness check in array filter. - */ -function notEmpty(value: T | undefined): value is T { - return value !== undefined; -} - /** * Google Analytics API provider for the Backstage Analytics API. */ export class GoogleAnalytics implements AnalyticsApi { - private readonly cdmConfig: CDM[]; + private readonly cdmConfig: CustomDimensionOrMetricConfig[]; /** * Instantiate the implementation and initialize ReactGA. @@ -52,7 +45,7 @@ export class GoogleAnalytics implements AnalyticsApi { testMode, debug, }: { - cdmConfig: CDM[]; + cdmConfig: CustomDimensionOrMetricConfig[]; trackingId: string; testMode: boolean; debug: boolean; @@ -69,19 +62,22 @@ export class GoogleAnalytics implements AnalyticsApi { static fromConfig(config: Config) { // Get all necessary configuration. const trackingId = config.getString('app.analytics.ga.trackingId'); - const debug = !!config.getOptionalBoolean('app.analytics.ga.debug'); - const testMode = !!config.getOptionalBoolean('app.analytics.ga.testMode'); + 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 CDM['type'], + type: c.getString('type') as CustomDimensionOrMetricConfig['type'], index: c.getNumber('index'), - source: c.getString('source') as CDM['source'], + source: c.getString( + 'source', + ) as CustomDimensionOrMetricConfig['source'], key: c.getString('key'), }; - }) || []; + }) ?? []; // Return an implementation instance. return new GoogleAnalytics({ @@ -106,7 +102,7 @@ export class GoogleAnalytics implements AnalyticsApi { }: AnalyticsEvent) { const customMetadata = this.getCustomDimensionMetrics(context, attributes); - if (action === 'navigate' && context?.extension === 'App') { + if (action === 'navigate' && context.extension === 'App') { // Set any/all custom dimensions. if (Object.keys(customMetadata).length) { ReactGA.set(customMetadata); @@ -127,34 +123,32 @@ export class GoogleAnalytics implements AnalyticsApi { /** * Returns an object of dimensions/metrics given an Analytics Context and an - * Event Context, e.g. { dimension1: "some value", metric8: 42 } + * Event Attributes, e.g. { dimension1: "some value", metric8: 42 } */ private getCustomDimensionMetrics( context: AnalyticsContextValue, attributes: AnalyticsEventAttributes = {}, ) { - const dataArray = this.cdmConfig - .map(cdm => { - const value = - cdm.source === 'context' ? context[cdm.key] : attributes[cdm.key]; + const customDimensionsMetrics: { [x: string]: string | number | boolean } = + {}; - // Never pass a non-numeric value on a metric. - if (cdm.type === 'metric' && typeof value !== 'number') { - return undefined; - } + this.cdmConfig.forEach(config => { + const value = + config.source === 'context' + ? context[config.key] + : attributes[config.key]; - return value !== undefined - ? { - [`${cdm.type}${cdm.index}`]: value, - } - : undefined; - }) - .filter(notEmpty); + // Never pass a non-numeric value on a metric. + if (config.type === 'metric' && typeof value !== 'number') { + return; + } - return dataArray.length - ? dataArray.reduce((result, cd) => { - return Object.assign(result, cd); - }) - : {}; + // Only set defined values. + if (value !== undefined) { + customDimensionsMetrics[`${config.type}${config.index}`] = value; + } + }); + + return customDimensionsMetrics; } } From 0da4df22a3e08fa3f6f441fe2386e45c9fb60fee Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 26 Sep 2021 19:53:21 +0200 Subject: [PATCH 22/25] Make 'to' prop an attribute, button text the primary subject. Signed-off-by: Eric Peterson --- ...ytics-haunts-dismembered-constellations.md | 15 +++++++---- .../src/components/Link/Link.test.tsx | 5 +++- .../src/components/Link/Link.tsx | 26 ++++++++++++++++++- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.changeset/analytics-haunts-dismembered-constellations.md b/.changeset/analytics-haunts-dismembered-constellations.md index 851527d852..e7cfc52ce1 100644 --- a/.changeset/analytics-haunts-dismembered-constellations.md +++ b/.changeset/analytics-haunts-dismembered-constellations.md @@ -4,17 +4,22 @@ The `` component now automatically instruments all link clicks using the new Analytics API. Each click triggers a `click` event, containing the -location the user clicked to. In addition, these events inherit plugin-level -metadata, allowing clicks to be attributed to the plugin containing the link: +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": "/value/of-the/to-prop/passed-to-the-link", + "subject": "Text content of the link that was clicked", + "attributes": { + "to": "/value/of-the/to-prop/passed-to-the-link" + }, "context": { - "extension": "SomeAssociatedExtension", + "extension": "ExtensionInWhichTheLinkWasClicked", "pluginId": "plugin-in-which-link-was-clicked", - "routeRef": "any-associated-route-ref-id" + "routeRef": "route-ref-in-which-the-link-was-clicked" } } ``` diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index 071b6fcffe..df1bcfac03 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -62,7 +62,10 @@ describe('', () => { await waitFor(() => { expect(analyticsApi.getEvents()[0]).toMatchObject({ action: 'click', - subject: '/test', + subject: linkText, + attributes: { + to: '/test', + }, }); // Custom onClick handler should have still been fired too. diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index fcfd2483de..d20773a8bb 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -34,6 +34,29 @@ export type LinkProps = MaterialLinkProps & declare function LinkType(props: LinkProps): JSX.Element; +/** + * Given a react node, try to retrieve its text content. + */ +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 @@ -43,12 +66,13 @@ 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', to); + analytics.captureEvent('click', linkText, { attributes: { to } }); }; return external ? ( From 4d11fcf683cdf9cc3fcf33eccb4c9c5c80402200 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 30 Sep 2021 15:47:02 +0200 Subject: [PATCH 23/25] Review feedback. Signed-off-by: Eric Peterson --- .changeset/analytics-sings-stormy-weather.md | 2 +- .github/styles/vocab.txt | 1 - packages/core-app-api/api-report.md | 10 ++++++++ .../AnalyticsApi/NoOpAnalyticsApi.ts | 23 +++++++++++++++++++ .../implementations/AnalyticsApi/index.ts | 17 ++++++++++++++ .../src/apis/implementations/index.ts | 1 + packages/core-app-api/src/app/App.test.tsx | 11 ++++++--- packages/core-app-api/src/app/defaultApis.ts | 3 +++ .../core-app-api/src/routing/RouteTracker.tsx | 2 ++ .../src/analytics/AnalyticsContext.tsx | 14 +++++------ .../src/analytics/useAnalytics.test.tsx | 11 --------- .../src/analytics/useAnalytics.tsx | 21 ++++------------- .../src/extensions/extensions.tsx | 2 ++ plugins/analytics-module-ga/README.md | 5 +--- plugins/analytics-module-ga/config.d.ts | 1 - 15 files changed, 78 insertions(+), 46 deletions(-) create mode 100644 packages/core-app-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts create mode 100644 packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts diff --git a/.changeset/analytics-sings-stormy-weather.md b/.changeset/analytics-sings-stormy-weather.md index 4b5eb1f237..f3e6becdf5 100644 --- a/.changeset/analytics-sings-stormy-weather.md +++ b/.changeset/analytics-sings-stormy-weather.md @@ -21,4 +21,4 @@ analytics context, which can be useful for analyzing plugin usage: ``` These events can be identified and handled by checking for the action -`navigate` and the componentName `App`. +`navigate` and the extension `App`. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7508ab8033..a38baab3dc 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -88,7 +88,6 @@ firehydrant FireHydrant Firekube Fiverr -ga gitbeaker GitHub GitLab 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 cd323fdb6e..7eb4220876 100644 --- a/packages/core-app-api/src/app/App.test.tsx +++ b/packages/core-app-api/src/app/App.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { LocalStorageFeatureFlags } from '../apis'; +import { LocalStorageFeatureFlags, NoOpAnalyticsApi } from '../apis'; import { MockAnalyticsApi, renderWithEffects, @@ -63,6 +63,10 @@ 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'] }); @@ -182,7 +186,7 @@ describe('Integration Test', () => { it('runs happy paths', async () => { const app = new PrivateAppImpl({ - apis: [], + apis: [noOpAnalyticsApi], defaultApis: [], themes: [ { @@ -236,7 +240,7 @@ describe('Integration Test', () => { it('runs happy paths without optional routes', async () => { const app = new PrivateAppImpl({ - apis: [], + apis: [noOpAnalyticsApi], defaultApis: [], themes: [ { @@ -282,6 +286,7 @@ describe('Integration Test', () => { jest.spyOn(storageFlags, 'registerFlag'); const apis = [ + noOpAnalyticsApi, createApiFactory({ api: featureFlagsApiRef, deps: { configApi: configApiRef }, 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 index 464f451c9c..b745dd9235 100644 --- a/packages/core-app-api/src/routing/RouteTracker.tsx +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -99,6 +99,8 @@ const TrackNavigation = ({ */ 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, diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx index 284b4186b2..f097fd577f 100644 --- a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ -import React, { createContext, ReactNode, useContext, useMemo } from 'react'; +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', @@ -47,13 +48,10 @@ export const AnalyticsContext = ({ children: ReactNode; }) => { const parentValues = useAnalyticsContext(); - const combinedValue = useMemo( - () => ({ - ...parentValues, - ...attributes, - }), - [parentValues, attributes], - ); + const combinedValue = { + ...parentValues, + ...attributes, + }; return ( diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx index 22021026af..3c932c02f7 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx @@ -23,17 +23,6 @@ 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(); diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx index 2dcb284879..c3a1ae3b9c 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -19,7 +19,10 @@ import { analyticsApiRef, AnalyticsTracker, useApi } from '../apis'; import { useRef } from 'react'; import { Tracker } from './Tracker'; -function useTracker(): AnalyticsTracker { +/** + * Get a pre-configured analytics tracker. + */ +export function useAnalytics(): AnalyticsTracker { const trackerRef = useRef(null); const analyticsApi = useApi(analyticsApiRef); const context = useAnalyticsContext(); @@ -36,19 +39,3 @@ function useTracker(): AnalyticsTracker { return tracker; } - -/** - * Get a pre-configured analytics tracker. - */ -export function useAnalytics(): AnalyticsTracker { - // Return a no-op tracker if no implementation for the Analytics API is - // available. Having no default Analytics API implementation enables simple - // provider installation via plugin instantiation. - try { - return useTracker(); - } catch { - return { - captureEvent: () => {}, - }; - } -} diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index 5427b5f61c..4aff027f31 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -133,6 +133,8 @@ 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; diff --git a/plugins/analytics-module-ga/README.md b/plugins/analytics-module-ga/README.md index 15ad4f1948..a0c4fe517a 100644 --- a/plugins/analytics-module-ga/README.md +++ b/plugins/analytics-module-ga/README.md @@ -36,7 +36,6 @@ events to GA. All that's needed is your Universal Analytics tracking ID: # app-config.yaml app: analytics: - provider: ga ga: trackingId: UA-0000000-0 ``` @@ -58,7 +57,6 @@ to capture Plugin IDs associated with events, including page views. ```yaml app: analytics: - provider: ga ga: trackingId: UA-0000000-0 customDimensionsMetrics: @@ -122,7 +120,7 @@ make and test changes is to do the following: 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 [/ga](http://localhost:3000/ga) +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. @@ -136,7 +134,6 @@ Paste this into your `app-config.local.yaml` while developing this plugin: ```yaml app: analytics: - provider: ga ga: trackingId: UA-0000000-0 debug: true diff --git a/plugins/analytics-module-ga/config.d.ts b/plugins/analytics-module-ga/config.d.ts index 161776e9dc..4ccd66e1dd 100644 --- a/plugins/analytics-module-ga/config.d.ts +++ b/plugins/analytics-module-ga/config.d.ts @@ -20,7 +20,6 @@ export interface Config { // context of the monorepo is too strict. Ideally, this would be marked as // required. analytics?: { - provider: 'ga'; ga: { /** * Google Analytics tracking ID, e.g. UA-000000-0 From f2e9fbf2b5d1651762811e26f1735ce80935939b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 1 Oct 2021 17:53:57 +0200 Subject: [PATCH 24/25] Update GA dependencies to match current template. Signed-off-by: Eric Peterson --- plugins/analytics-module-ga/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 71168f819c..787c23c301 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -22,8 +22,8 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.8", + "@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", @@ -34,9 +34,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/core-app-api": "^0.1.14", - "@backstage/dev-utils": "^0.2.10", + "@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", From decaaae1ed3d73b2f831f96f5c65a73ffc23fd93 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 4 Oct 2021 15:59:27 +0200 Subject: [PATCH 25/25] Keep optionality of API, but try and maintain hook order. Signed-off-by: Eric Peterson --- .../src/analytics/useAnalytics.test.tsx | 11 ++++++++++ .../src/analytics/useAnalytics.tsx | 20 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx index 3c932c02f7..22021026af 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx @@ -23,6 +23,17 @@ 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(); diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx index c3a1ae3b9c..02d1ecc3c3 100644 --- a/packages/core-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -15,17 +15,33 @@ */ import { useAnalyticsContext } from './AnalyticsContext'; -import { analyticsApiRef, AnalyticsTracker, useApi } from '../apis'; +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 analyticsApi = useApi(analyticsApiRef); 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) {