Add the Analytics API to core-plugin-api
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': minor
|
||||
---
|
||||
|
||||
Introducing the Analytics API: a lightweight way for plugins to instrument key
|
||||
events that could help inform a Backstage Integrator how their instance of
|
||||
Backstage is being used. The API consists of the following:
|
||||
|
||||
- `useAnalytics()`, a hook to be used inside plugin components which retrieves
|
||||
an Analytics Tracker.
|
||||
- `tracker.captureEvent()`, a method on the tracker used to instrument key
|
||||
events. The method expects a verb (the action performed), a noun (a unique
|
||||
identifier of the object the action is being taken on), and optionally a
|
||||
numeric metric/value.
|
||||
- `<AnalyticsDomain />`, a way to declaratively attach additional information
|
||||
to any/all events captured in the underlying React tree. There is also a
|
||||
`withAnalyticsDomain()` HOC utility.
|
||||
- The `tracker.captureEvent()` method also accepts a `context` object for
|
||||
optionally providing additional run-time information about an event.
|
||||
|
||||
By default, captured events are not sent anywhere. In order to collect and
|
||||
redirect events to an analytics system, the `analyticsApi` will need to be
|
||||
implemented and instantiated by an App Integrator.
|
||||
@@ -34,6 +34,76 @@ export type AlertMessage = {
|
||||
severity?: 'success' | 'info' | 'warning' | 'error';
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type AnalyticsApi = {
|
||||
getDecoratedTracker({
|
||||
domain,
|
||||
}: {
|
||||
domain: AnalyticsDomainValue;
|
||||
}): AnalyticsTracker;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "analyticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const analyticsApiRef: ApiRef<AnalyticsApi>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export const AnalyticsDomain: ({
|
||||
attributes,
|
||||
children,
|
||||
}: {
|
||||
attributes: AnalyticsDomainValue;
|
||||
children: ReactNode;
|
||||
}) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AnalyticsDomainValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type AnalyticsDomainValue = Partial<
|
||||
RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain
|
||||
>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type AnalyticsEvent = {
|
||||
verb: string;
|
||||
noun: string;
|
||||
value?: number;
|
||||
context?: AnalyticsEventContext;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AnalyticsEventContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type AnalyticsEventContext = {
|
||||
[attribute in string]: string | boolean | number;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AnalyticsTracker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type AnalyticsTracker = {
|
||||
captureEvent: (
|
||||
verb: string,
|
||||
noun: string,
|
||||
value?: number,
|
||||
context?: AnalyticsEventContext,
|
||||
) => void;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AnyAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type AnyAnalyticsDomain = {
|
||||
[param in string]: string | boolean | number | undefined;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AnyApiFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -234,6 +304,14 @@ export type BootErrorPageProps = {
|
||||
error: Error;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "ComponentAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type ComponentAnalyticsDomain = {
|
||||
pluginId: string;
|
||||
componentName: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "ConfigApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
@@ -273,7 +351,7 @@ export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
|
||||
// @public (undocumented)
|
||||
export function createComponentExtension<
|
||||
T extends (props: any) => JSX.Element | null,
|
||||
>(options: { component: ComponentLoader<T> }): Extension<T>;
|
||||
>(options: { component: ComponentLoader<T>; name?: string }): Extension<T>;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "OptionalParams" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "createExternalRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -309,6 +387,7 @@ export function createReactExtension<
|
||||
>(options: {
|
||||
component: ComponentLoader<T>;
|
||||
data?: Record<string, unknown>;
|
||||
name?: string;
|
||||
}): Extension<T>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createRoutableExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -316,7 +395,11 @@ export function createReactExtension<
|
||||
// @public (undocumented)
|
||||
export function createRoutableExtension<
|
||||
T extends (props: any) => JSX.Element | null,
|
||||
>(options: { component: () => Promise<T>; mountPoint: RouteRef }): Extension<T>;
|
||||
>(options: {
|
||||
component: () => Promise<T>;
|
||||
mountPoint: RouteRef;
|
||||
name?: string;
|
||||
}): Extension<T>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
@@ -361,6 +444,13 @@ export type DiscoveryApi = {
|
||||
// @public (undocumented)
|
||||
export const discoveryApiRef: ApiRef<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
|
||||
@@ -704,6 +794,15 @@ 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)
|
||||
@@ -832,6 +931,11 @@ export type TypesToApiRefs<T> = {
|
||||
[key in keyof T]: ApiRef<T[key]>;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "useAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export function useAnalytics(): AnalyticsTracker;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "useApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -882,6 +986,17 @@ export function useRouteRefParams<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)
|
||||
//
|
||||
// @public
|
||||
export function withAnalyticsDomain<P>(
|
||||
Component: React_2.ComponentType<P>,
|
||||
domain: AnyAnalyticsDomain,
|
||||
): {
|
||||
(props: P): JSX.Element;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "withApis" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -900,7 +1015,7 @@ export function withApis<T>(apis: TypesToApiRefs<T>): <P extends T>(
|
||||
// src/apis/definitions/auth.d.ts:96:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration
|
||||
// src/apis/definitions/auth.d.ts:110:16 - (tsdoc-undefined-tag) The TSDoc tag "@IdentityApi" is not defined in this configuration
|
||||
// src/apis/definitions/auth.d.ts:113:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration
|
||||
// src/extensions/extensions.d.ts:14:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts
|
||||
// src/extensions/extensions.d.ts:15:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts
|
||||
// src/routing/RouteRef.d.ts:35:5 - (ae-forgotten-export) The symbol "OldIconComponent" needs to be exported by the entry point index.d.ts
|
||||
// src/routing/types.d.ts:30:5 - (ae-forgotten-export) The symbol "ParamKeys" needs to be exported by the entry point index.d.ts
|
||||
```
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import {
|
||||
AnalyticsDomain,
|
||||
useAnalyticsDomain,
|
||||
withAnalyticsDomain,
|
||||
} from './AnalyticsDomain';
|
||||
|
||||
const DomainSpy = () => {
|
||||
const domain = useAnalyticsDomain();
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AnalyticsDomain', () => {
|
||||
describe('useAnalyticsDomain', () => {
|
||||
it('returns default values', () => {
|
||||
const { result } = renderHook(() => useAnalyticsDomain());
|
||||
expect(result.current).toEqual({
|
||||
componentName: 'App',
|
||||
pluginId: 'root',
|
||||
routeRef: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnalyticsDomain', () => {
|
||||
it('uses default analytics domain', () => {
|
||||
const result = render(
|
||||
<AnalyticsDomain attributes={{}}>
|
||||
<DomainSpy />
|
||||
</AnalyticsDomain>,
|
||||
);
|
||||
|
||||
expect(result.getByTestId('component-name')).toHaveTextContent('App');
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('root');
|
||||
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
|
||||
});
|
||||
|
||||
it('uses provided analytics domain', () => {
|
||||
const result = render(
|
||||
<AnalyticsDomain attributes={{ pluginId: 'custom' }}>
|
||||
<DomainSpy />
|
||||
</AnalyticsDomain>,
|
||||
);
|
||||
|
||||
expect(result.getByTestId('component-name')).toHaveTextContent('App');
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
|
||||
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
|
||||
});
|
||||
|
||||
it('uses nested analytics domain', () => {
|
||||
const result = render(
|
||||
<AnalyticsDomain attributes={{ pluginId: 'custom' }}>
|
||||
<AnalyticsDomain attributes={{ componentName: 'DomainSpy' }}>
|
||||
<DomainSpy />
|
||||
</AnalyticsDomain>
|
||||
</AnalyticsDomain>,
|
||||
);
|
||||
|
||||
expect(result.getByTestId('component-name')).toHaveTextContent(
|
||||
'DomainSpy',
|
||||
);
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
|
||||
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAnalyticsDomain', () => {
|
||||
it('wraps component with analytics domain', () => {
|
||||
const DomainSpyHOC = withAnalyticsDomain(DomainSpy, { custom: 'attr' });
|
||||
const result = render(<DomainSpyHOC />);
|
||||
expect(result.getByTestId('custom')).toHaveTextContent('attr');
|
||||
expect(DomainSpyHOC.displayName).toBe('WithAnalyticsDomain(DomainSpy)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createContext, ReactNode, useContext } from 'react';
|
||||
import { AnalyticsDomainValue, AnyAnalyticsDomain } from './types';
|
||||
|
||||
export const AnalyticsDomainContext = createContext<AnalyticsDomainValue>({
|
||||
routeRef: 'unknown',
|
||||
pluginId: 'root',
|
||||
componentName: 'App',
|
||||
});
|
||||
|
||||
/**
|
||||
* A "private" (to this package) hook that enables context inheritance and a
|
||||
* way to read Analytics Domain values at event capture-time.
|
||||
* @private
|
||||
*/
|
||||
export const useAnalyticsDomain = () => {
|
||||
return useContext(AnalyticsDomainContext);
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides components in the child react tree an Analytics Domain, ensuring
|
||||
* all analytics events captured within the domain have relevant contextual
|
||||
* attributes.
|
||||
*
|
||||
* Analytics domains are additive, meaning the domain ultimately emitted with
|
||||
* an event is the combination of all domains in the parent tree.
|
||||
*/
|
||||
export const AnalyticsDomain = ({
|
||||
attributes,
|
||||
children,
|
||||
}: {
|
||||
attributes: AnalyticsDomainValue;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const parentValues = useAnalyticsDomain();
|
||||
const combinedValue = {
|
||||
...parentValues,
|
||||
...attributes,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsDomainContext.Provider value={combinedValue}>
|
||||
{children}
|
||||
</AnalyticsDomainContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an HOC wrapping the provided component in an Analytics Domain with
|
||||
* the given values.
|
||||
*
|
||||
* @param Component - Component to be wrapped with analytics domain attributes.
|
||||
* @param domain - Analytics domain key/value pairs.
|
||||
*/
|
||||
export function withAnalyticsDomain<P>(
|
||||
Component: React.ComponentType<P>,
|
||||
domain: AnyAnalyticsDomain,
|
||||
) {
|
||||
const ComponentWithAnalyticsDomain = (props: P) => {
|
||||
return (
|
||||
<AnalyticsDomain attributes={domain}>
|
||||
<Component {...props} />
|
||||
</AnalyticsDomain>
|
||||
);
|
||||
};
|
||||
ComponentWithAnalyticsDomain.displayName = `WithAnalyticsDomain(${
|
||||
Component.displayName || Component.name || 'Component'
|
||||
})`;
|
||||
return ComponentWithAnalyticsDomain;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { AnalyticsDomain, withAnalyticsDomain } from './AnalyticsDomain';
|
||||
export type {
|
||||
AnalyticsDomainValue,
|
||||
AnyAnalyticsDomain,
|
||||
ComponentAnalyticsDomain,
|
||||
RoutableAnalyticsDomain,
|
||||
} from './types';
|
||||
export { useAnalytics } from './useAnalytics';
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Analytics domain covering routable extensions.
|
||||
*/
|
||||
export type RoutableAnalyticsDomain = {
|
||||
/**
|
||||
* The plugin that exposed the route.
|
||||
*/
|
||||
pluginId: string;
|
||||
|
||||
/**
|
||||
* The ID of the route ref associated with the route.
|
||||
*/
|
||||
routeRef: string;
|
||||
|
||||
/**
|
||||
* The name of the component used to render the route.
|
||||
*/
|
||||
componentName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analytics domain covering component extensions.
|
||||
*/
|
||||
export type ComponentAnalyticsDomain = {
|
||||
/**
|
||||
* The plugin that exposed the component.
|
||||
*/
|
||||
pluginId: string;
|
||||
|
||||
/**
|
||||
* The name of the component.
|
||||
*/
|
||||
componentName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Allow arbitrary scalar values as domain attributes too.
|
||||
*/
|
||||
export type AnyAnalyticsDomain = {
|
||||
[param in string]: string | boolean | number | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Common analytics domain attributes.
|
||||
*/
|
||||
export type AnalyticsDomainValue = Partial<
|
||||
RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain
|
||||
>;
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
import { useApi } from '../apis';
|
||||
|
||||
jest.mock('../apis');
|
||||
|
||||
const mocked = (f: Function) => f as jest.Mock;
|
||||
|
||||
describe('useAnalytics', () => {
|
||||
it('returns tracker with no implementation defined', () => {
|
||||
// Simulate useApi() throwing an error.
|
||||
mocked(useApi).mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
|
||||
// Result should still have a captureEvent method.
|
||||
const { result } = renderHook(() => useAnalytics());
|
||||
expect(result.current.captureEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns tracker from defined analytics api', () => {
|
||||
const expectedFunction = 'capture function';
|
||||
const getDecoratedTracker = jest.fn().mockReturnValue({
|
||||
captureEvent: expectedFunction,
|
||||
});
|
||||
|
||||
// Simulate useApi returning a valid tracker.
|
||||
mocked(useApi).mockReturnValue({ getDecoratedTracker });
|
||||
|
||||
// The getDecoratedTracker method of the underlying implementation should
|
||||
// have been called with the domain provided.
|
||||
const { result } = renderHook(() => useAnalytics());
|
||||
expect(getDecoratedTracker).toHaveBeenCalledWith({
|
||||
domain: {
|
||||
componentName: 'App',
|
||||
pluginId: 'root',
|
||||
routeRef: 'unknown',
|
||||
},
|
||||
});
|
||||
|
||||
// And the returned tracker's captureEvent should have come from the API
|
||||
// implementation.
|
||||
expect(result.current.captureEvent).toBe(expectedFunction);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useAnalyticsDomain } from './AnalyticsDomain';
|
||||
import {
|
||||
analyticsApiRef,
|
||||
AnalyticsTracker,
|
||||
} from '../apis/definitions/AnalyticsApi';
|
||||
import { useApi } from '../apis';
|
||||
|
||||
function useTracker(): AnalyticsTracker {
|
||||
const analyticsApi = useApi(analyticsApiRef);
|
||||
const domain = useAnalyticsDomain();
|
||||
return analyticsApi.getDecoratedTracker({ domain });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pre-configured analytics tracker.
|
||||
*/
|
||||
export function useAnalytics(): AnalyticsTracker {
|
||||
// Return a no-op tracker if no implementation for the Analytics API is
|
||||
// available. Having no default Analytics API implementation enables simple
|
||||
// provider installation via plugin instantiation.
|
||||
try {
|
||||
return useTracker();
|
||||
} catch {
|
||||
return {
|
||||
captureEvent: () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { AnalyticsDomainValue } from '../../analytics/types';
|
||||
|
||||
/**
|
||||
* Represents an event worth tracking in an analytics system that could inform
|
||||
* how users of a Backstage instance are using its features.
|
||||
*
|
||||
* Note that attributes about the Backstage user or about the plugin tracking
|
||||
* the event are inferred and captured separately on the Analytics Domain and
|
||||
* do not need to be passed on the event itself.
|
||||
*/
|
||||
export type AnalyticsEvent = {
|
||||
/**
|
||||
* A string that identifies the event being tracked by the type of action the
|
||||
* event represents. Examples include:
|
||||
*
|
||||
* - view
|
||||
* - click
|
||||
* - filter
|
||||
* - search
|
||||
* - hover
|
||||
* - scroll
|
||||
*/
|
||||
verb: string;
|
||||
|
||||
/**
|
||||
* A string that uniquely identifies the object that the verb or action is
|
||||
* being taken on. Examples include:
|
||||
*
|
||||
* - The path of the page viewed
|
||||
* - The url of the link clicked
|
||||
* - The value that was filtered by
|
||||
* - The text that was searched for
|
||||
*/
|
||||
noun: string;
|
||||
|
||||
/**
|
||||
* An optional numeric value relevant to the event that could be aggregated
|
||||
* by analytics tools. Examples include:
|
||||
*
|
||||
* - The index or position of the clicked element in an ordered list
|
||||
* - The percentage of an element that has been scrolled through
|
||||
* - The amount of time that has elapsed since a fixed point
|
||||
*/
|
||||
value?: number;
|
||||
|
||||
/**
|
||||
* Optional context with any additional dimensions or metrics that could be
|
||||
* forwarded on to analytics systems.
|
||||
*/
|
||||
context?: AnalyticsEventContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* An analytics event combined with domain attributes.
|
||||
*/
|
||||
export type DomainDecoratedAnalyticsEvent = AnalyticsEvent & {
|
||||
/**
|
||||
* Domain metadata relating to where the event was captured and by whom. This
|
||||
* could include information about the route, plugin, or component in which
|
||||
* an event was captured.
|
||||
*/
|
||||
domain: AnalyticsDomainValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* A structure allowing other arbitrary metadata to be provided by analytics
|
||||
* event emitters.
|
||||
*/
|
||||
export type AnalyticsEventContext = {
|
||||
[attribute in string]: string | boolean | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a tracker with methods that can be called to track events in a
|
||||
* configured analytics service.
|
||||
*/
|
||||
export type AnalyticsTracker = {
|
||||
captureEvent: (
|
||||
verb: string,
|
||||
noun: string,
|
||||
value?: number,
|
||||
context?: AnalyticsEventContext,
|
||||
) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Analytics API is used to track user behavior in a Backstage instance.
|
||||
*
|
||||
* To instrument your App or Plugin, retrieve an analytics tracker using the
|
||||
* useAnalytics() hook. This will return a pre-configured AnalyticsTracker
|
||||
* with relevant methods for instrumentation.
|
||||
*/
|
||||
export type AnalyticsApi = {
|
||||
/**
|
||||
* Retrieves a tracker decorated with a given analytics domain.
|
||||
*/
|
||||
getDecoratedTracker({
|
||||
domain,
|
||||
}: {
|
||||
domain: AnalyticsDomainValue;
|
||||
}): AnalyticsTracker;
|
||||
};
|
||||
|
||||
export const analyticsApiRef: ApiRef<AnalyticsApi> = createApiRef({
|
||||
id: 'core.analytics',
|
||||
});
|
||||
@@ -23,6 +23,7 @@
|
||||
export * from './auth';
|
||||
|
||||
export * from './AlertApi';
|
||||
export * from './AnalyticsApi';
|
||||
export * from './AppThemeApi';
|
||||
export * from './ConfigApi';
|
||||
export * from './DiscoveryApi';
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { withLogCollector } from '@backstage/test-utils-core';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { useAnalyticsDomain } from '../analytics/AnalyticsDomain';
|
||||
import { useApp, ErrorBoundaryFallbackProps } from '../app';
|
||||
import { createPlugin } from '../plugin';
|
||||
import { createRouteRef } from '../routing';
|
||||
@@ -107,4 +108,32 @@ describe('extensions', () => {
|
||||
screen.getByText('Error in my-plugin');
|
||||
expect(errors[0]).toMatch('Test error');
|
||||
});
|
||||
|
||||
it('should wrap extended component with analytics domain', async () => {
|
||||
const DomainSpyExtension = plugin.provide(
|
||||
createReactExtension({
|
||||
name: 'DomainSpy',
|
||||
component: {
|
||||
sync: () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const domain = useAnalyticsDomain();
|
||||
return (
|
||||
<>
|
||||
<div data-testid="plugin-id">{domain.pluginId}</div>
|
||||
<div data-testid="route-ref">{domain.routeRef}</div>
|
||||
<div data-testid="component-name">{domain.componentName}</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
data: { 'core.mountpoint': { id: 'some-ref' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = render(<DomainSpyExtension />);
|
||||
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('my-plugin');
|
||||
expect(result.getByTestId('route-ref')).toHaveTextContent('some-ref');
|
||||
expect(result.getByTestId('component-name')).toHaveTextContent('DomainSpy');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { AnalyticsDomain } from '../analytics/AnalyticsDomain';
|
||||
import { useApp } from '../app';
|
||||
import { RouteRef, useRouteRef } from '../routing';
|
||||
import { attachComponentData } from './componentData';
|
||||
@@ -94,7 +95,7 @@ export function createRoutableExtension<
|
||||
// ComponentType inserts children as an optional prop whether the inner component accepts it or not,
|
||||
// making it impossible to make the usage of children type safe.
|
||||
export function createComponentExtension<
|
||||
T extends (props: any) => JSX.Element | null
|
||||
T extends (props: any) => JSX.Element | null,
|
||||
>(options: { component: ComponentLoader<T>; name?: string }): Extension<T> {
|
||||
const { component, name } = options;
|
||||
return createReactExtension({ component, name });
|
||||
@@ -136,7 +137,20 @@ export function createReactExtension<
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<PluginErrorBoundary app={app} plugin={plugin}>
|
||||
<Component {...props} />
|
||||
<AnalyticsDomain
|
||||
attributes={{
|
||||
pluginId: plugin.getId(),
|
||||
componentName,
|
||||
...(data['core.mountpoint']
|
||||
? {
|
||||
routeRef: (data['core.mountpoint'] as { id?: string })
|
||||
.id,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<Component {...props} />
|
||||
</AnalyticsDomain>
|
||||
</PluginErrorBoundary>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './analytics';
|
||||
export * from './apis';
|
||||
export * from './app';
|
||||
export * from './extensions';
|
||||
|
||||
Reference in New Issue
Block a user