Simplify/clarify Analytics API types and naming.
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
- `<AnalyticsDomain />`, 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).
|
||||
- `<AnalyticsContext />`, 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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -377,22 +377,21 @@ describe('Integration Test', () => {
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -24,23 +24,23 @@ import {
|
||||
BackstagePlugin,
|
||||
useAnalytics,
|
||||
getComponentData,
|
||||
AnalyticsDomain,
|
||||
RoutableAnalyticsDomain,
|
||||
AnalyticsContext,
|
||||
CommonAnalyticsContext,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
type RouteObjects = ReturnType<typeof createRoutesFromChildren>;
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<AnalyticsDomain attributes={attributes}>
|
||||
<AnalyticsContext attributes={attributes}>
|
||||
<TrackNavigation pathname={pathname} search={search} hash={hash} />
|
||||
</AnalyticsDomain>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,9 +63,9 @@ describe('<Link />', () => {
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AnalyticsApi>;
|
||||
|
||||
// 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<DiscoveryApi>;
|
||||
|
||||
// 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<ProfileInfo | undefined>;
|
||||
};
|
||||
|
||||
// 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<Params extends AnyParams>(
|
||||
_routeRef: RouteRef<Params> | SubRouteRef<Params>,
|
||||
): 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<P>(
|
||||
export function withAnalyticsContext<P>(
|
||||
Component: React_2.ComponentType<P>,
|
||||
domain: AnyAnalyticsDomain,
|
||||
values: AnalyticsContextValue,
|
||||
): {
|
||||
(props: P): JSX.Element;
|
||||
displayName: string;
|
||||
|
||||
+38
-34
@@ -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 (
|
||||
<>
|
||||
<div data-testid="route-ref">{domain.routeRef}</div>
|
||||
<div data-testid="plugin-id">{domain.pluginId}</div>
|
||||
<div data-testid="component-name">{domain.componentName}</div>
|
||||
<div data-testid="custom">{domain.custom}</div>
|
||||
<div data-testid="route-ref">{context.routeRef}</div>
|
||||
<div data-testid="plugin-id">{context.pluginId}</div>
|
||||
<div data-testid="component-name">{context.componentName}</div>
|
||||
<div data-testid="custom">{context.custom}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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(
|
||||
<AnalyticsDomain attributes={{}}>
|
||||
<DomainSpy />
|
||||
</AnalyticsDomain>,
|
||||
<AnalyticsContext attributes={{}}>
|
||||
<AnalyticsSpy />
|
||||
</AnalyticsContext>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AnalyticsDomain attributes={{ pluginId: 'custom' }}>
|
||||
<DomainSpy />
|
||||
</AnalyticsDomain>,
|
||||
<AnalyticsContext attributes={{ pluginId: 'custom' }}>
|
||||
<AnalyticsSpy />
|
||||
</AnalyticsContext>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AnalyticsDomain attributes={{ pluginId: 'custom' }}>
|
||||
<AnalyticsDomain attributes={{ componentName: 'DomainSpy' }}>
|
||||
<DomainSpy />
|
||||
</AnalyticsDomain>
|
||||
</AnalyticsDomain>,
|
||||
<AnalyticsContext attributes={{ pluginId: 'custom' }}>
|
||||
<AnalyticsContext attributes={{ componentName: 'AnalyticsSpy' }}>
|
||||
<AnalyticsSpy />
|
||||
</AnalyticsContext>
|
||||
</AnalyticsContext>,
|
||||
);
|
||||
|
||||
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(<DomainSpyHOC />);
|
||||
describe('withAnalyticsContext', () => {
|
||||
it('wraps component with analytics context', () => {
|
||||
const AnalyticsSpyHOC = withAnalyticsContext(AnalyticsSpy, {
|
||||
custom: 'attr',
|
||||
});
|
||||
const result = render(<AnalyticsSpyHOC />);
|
||||
expect(result.getByTestId('custom')).toHaveTextContent('attr');
|
||||
expect(DomainSpyHOC.displayName).toBe('WithAnalyticsDomain(DomainSpy)');
|
||||
expect(AnalyticsSpyHOC.displayName).toBe(
|
||||
'WithAnalyticsContext(AnalyticsSpy)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+24
-25
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, ReactNode, useContext } from 'react';
|
||||
import { AnalyticsDomainValue, AnyAnalyticsDomain } from './types';
|
||||
import { AnalyticsContextValue } from './types';
|
||||
|
||||
export const AnalyticsDomainContext = createContext<AnalyticsDomainValue>({
|
||||
const AnalyticsReactContext = createContext<AnalyticsContextValue>({
|
||||
routeRef: 'unknown',
|
||||
pluginId: 'root',
|
||||
componentName: 'App',
|
||||
@@ -25,61 +25,60 @@ export const AnalyticsDomainContext = createContext<AnalyticsDomainValue>({
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<AnalyticsDomainContext.Provider value={combinedValue}>
|
||||
<AnalyticsReactContext.Provider value={combinedValue}>
|
||||
{children}
|
||||
</AnalyticsDomainContext.Provider>
|
||||
</AnalyticsReactContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<P>(
|
||||
export function withAnalyticsContext<P>(
|
||||
Component: React.ComponentType<P>,
|
||||
domain: AnyAnalyticsDomain,
|
||||
values: AnalyticsContextValue,
|
||||
) {
|
||||
const ComponentWithAnalyticsDomain = (props: P) => {
|
||||
const ComponentWithAnalyticsContext = (props: P) => {
|
||||
return (
|
||||
<AnalyticsDomain attributes={domain}>
|
||||
<AnalyticsContext attributes={values}>
|
||||
<Component {...props} />
|
||||
</AnalyticsDomain>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
ComponentWithAnalyticsDomain.displayName = `WithAnalyticsDomain(${
|
||||
ComponentWithAnalyticsContext.displayName = `WithAnalyticsContext(${
|
||||
Component.displayName || Component.name || 'Component'
|
||||
})`;
|
||||
return ComponentWithAnalyticsDomain;
|
||||
return ComponentWithAnalyticsContext;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AnalyticsApi> = createApiRef({
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div data-testid="plugin-id">{domain.pluginId}</div>
|
||||
<div data-testid="route-ref">{domain.routeRef}</div>
|
||||
<div data-testid="component-name">{domain.componentName}</div>
|
||||
<div data-testid="plugin-id">{context.pluginId}</div>
|
||||
<div data-testid="route-ref">{context.routeRef}</div>
|
||||
<div data-testid="component-name">{context.componentName}</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -130,10 +130,12 @@ describe('extensions', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = render(<DomainSpyExtension />);
|
||||
const result = render(<AnalyticsSpyExtension />);
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<PluginErrorBoundary app={app} plugin={plugin}>
|
||||
<AnalyticsDomain
|
||||
<AnalyticsContext
|
||||
attributes={{
|
||||
pluginId: plugin.getId(),
|
||||
componentName,
|
||||
@@ -150,7 +150,7 @@ export function createReactExtension<
|
||||
}}
|
||||
>
|
||||
<Component {...props} />
|
||||
</AnalyticsDomain>
|
||||
</AnalyticsContext>
|
||||
</PluginErrorBoundary>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-7
@@ -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;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Link to="#clicked">Click Here</Link>
|
||||
@@ -26,4 +26,4 @@ const DomainlessPlayground = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Playground = withAnalyticsDomain(DomainlessPlayground, {});
|
||||
export const Playground = withAnalyticsContext(ContextlessPlayground, {});
|
||||
|
||||
+32
-32
@@ -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];
|
||||
|
||||
+21
-23
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user