From 1ff738629165050e957a4206d7e5b88ea6655cb0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 16 Sep 2021 14:16:16 +0200 Subject: [PATCH] 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') {