Merge pull request #14118 from backstage/analytics/entity-awareness
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Basic analytics instrumentation is now in place:
|
||||
|
||||
- As users make their way through template steps, a `click` event is fired, including the step number.
|
||||
- After a user clicks "Create" a `create` event is fired, including the name of the software that was just created. The template used at creation is set on the `entityRef` context key.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
Both `EntityProvider` and `AsyncEntityProvider` contexts now wrap all children with an `AnalyticsContext` containing the corresponding `entityRef`; this opens up the possibility for all events underneath these contexts to be associated with and aggregated by the corresponding entity.
|
||||
+12
-11
@@ -52,12 +52,13 @@ learn how to contribute the integration yourself!
|
||||
The following table summarizes events that, depending on the plugins you have
|
||||
installed, may be captured.
|
||||
|
||||
| Action | Subject | Other Notes |
|
||||
| ---------- | --------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `navigate` | The URL of the page that was navigated to | |
|
||||
| `click` | The text of the link that was clicked on | The `to` attribute represents the URL clicked to |
|
||||
| `search` | The search term entered in any search bar component | The `searchTypes` attribute holds `types` constraining the search |
|
||||
| `discover` | The title of the search result that was clicked on | The `value` is the result rank. A `to` attribute is also provided |
|
||||
| Action | Subject | Other Notes |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `navigate` | The URL of the page that was navigated to | |
|
||||
| `click` | The text of the link that was clicked on | The `to` attribute represents the URL clicked to |
|
||||
| `create` | The `name` of the software being created; if no `name` property is requested by the given Software Template, then the string `new {templateName}` is used instead. | The context holds an `entityRef`, set to the template's ref (e.g. `template:default/template-name`) |
|
||||
| `search` | The search term entered in any search bar component | The context holds `searchTypes`, representing `types` constraining the search |
|
||||
| `discover` | The title of the search result that was clicked on | The `value` is the result rank. A `to` attribute is also provided |
|
||||
|
||||
If there is an event you'd like to see captured, please [open an
|
||||
issue][add-event] describing the event you want to see and the questions it
|
||||
@@ -301,11 +302,11 @@ it's important to keep each of these levels of detail disaggregated.
|
||||
automatically as part of the `extension` in which the `filter` event was
|
||||
captured).
|
||||
|
||||
- On the flip side, when adding `attributes` to an event, look at existing
|
||||
events and see if the data you are capturing matches the intention, type, or
|
||||
even the content of _their_ `attributes`. For instance, it may be common for
|
||||
events that involve the Catalog to add details like entity `name`, `kind`,
|
||||
and/or `namespace` as `attributes`. Using the same keys in your event will
|
||||
- On the flip side, when adding `attributes` to or `context` around an event,
|
||||
look at existing events and see if the data you are capturing matches the
|
||||
intention, type, or even the content of _their_ `attributes` or `context`.
|
||||
For instance, it's common for events that involve the Catalog to include an
|
||||
`entityRef` contextual key. Using the same keys and values in your event will
|
||||
ensure that events instrumented across plugins can easily be aggregated.
|
||||
|
||||
### Unit Testing Event Capture
|
||||
|
||||
@@ -38,7 +38,10 @@ describe('EntityBadgesDialog', () => {
|
||||
},
|
||||
]),
|
||||
};
|
||||
const mockEntity = { metadata: { name: 'mock' } } as Entity;
|
||||
const mockEntity = {
|
||||
metadata: { name: 'mock' },
|
||||
kind: 'MockKind',
|
||||
} as Entity;
|
||||
|
||||
const rendered = await renderWithEffects(
|
||||
<TestApiProvider
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
AsyncEntityProvider,
|
||||
} from './useEntity';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { analyticsApiRef, useAnalytics } from '@backstage/core-plugin-api';
|
||||
import { MockAnalyticsApi, TestApiRegistry } from '@backstage/test-utils';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
|
||||
const entity = { metadata: { name: 'my-entity' }, kind: 'MyKind' } as Entity;
|
||||
|
||||
describe('useEntity', () => {
|
||||
it('should throw if no entity is provided', async () => {
|
||||
@@ -34,7 +39,6 @@ describe('useEntity', () => {
|
||||
});
|
||||
|
||||
it('should provide an entity', async () => {
|
||||
const entity = { kind: 'MyEntity' } as Entity;
|
||||
const { result } = renderHook(() => useEntity(), {
|
||||
wrapper: ({ children }) => (
|
||||
<EntityProvider entity={entity} children={children} />
|
||||
@@ -43,6 +47,24 @@ describe('useEntity', () => {
|
||||
|
||||
expect(result.current.entity).toBe(entity);
|
||||
});
|
||||
|
||||
it('should provide entityRef analytics context', () => {
|
||||
const analyticsSpy = new MockAnalyticsApi();
|
||||
const apis = TestApiRegistry.from([analyticsApiRef, analyticsSpy]);
|
||||
const { result } = renderHook(() => useAnalytics(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ApiProvider apis={apis}>
|
||||
<EntityProvider entity={entity} children={children} />
|
||||
</ApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
result.current.captureEvent('test', 'value');
|
||||
|
||||
expect(analyticsSpy.getEvents()[0]).toMatchObject({
|
||||
context: { entityRef: 'mykind:default/my-entity' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAsyncEntity', () => {
|
||||
@@ -60,7 +82,6 @@ describe('useAsyncEntity', () => {
|
||||
});
|
||||
|
||||
it('should provide an entity', async () => {
|
||||
const entity = { kind: 'MyEntity' } as Entity;
|
||||
const refresh = () => {};
|
||||
const { result } = renderHook(() => useAsyncEntity(), {
|
||||
wrapper: ({ children }) => (
|
||||
@@ -96,4 +117,43 @@ describe('useAsyncEntity', () => {
|
||||
expect(result.current.error).toBe(error);
|
||||
expect(result.current.refresh).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should provide entityRef analytics context', () => {
|
||||
const analyticsSpy = new MockAnalyticsApi();
|
||||
const apis = TestApiRegistry.from([analyticsApiRef, analyticsSpy]);
|
||||
const { result } = renderHook(() => useAnalytics(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ApiProvider apis={apis}>
|
||||
<AsyncEntityProvider
|
||||
loading={false}
|
||||
entity={entity}
|
||||
refresh={() => {}}
|
||||
children={children}
|
||||
/>
|
||||
</ApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
result.current.captureEvent('test', 'value');
|
||||
|
||||
expect(analyticsSpy.getEvents()[0]).toMatchObject({
|
||||
context: { entityRef: 'mykind:default/my-entity' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit entityRef analytics context', () => {
|
||||
const analyticsSpy = new MockAnalyticsApi();
|
||||
const apis = TestApiRegistry.from([analyticsApiRef, analyticsSpy]);
|
||||
const { result } = renderHook(() => useAnalytics(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ApiProvider apis={apis}>
|
||||
<AsyncEntityProvider loading={false} children={children} />
|
||||
</ApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
result.current.captureEvent('test', 'value');
|
||||
|
||||
expect(analyticsSpy.getEvents()[0].context).not.toHaveProperty('entityRef');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { AnalyticsContext } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
createVersionedContext,
|
||||
createVersionedValueMap,
|
||||
@@ -66,7 +67,13 @@ export const AsyncEntityProvider = ({
|
||||
// consumers might be doing things like `useContext(EntityContext)`
|
||||
return (
|
||||
<NewEntityContext.Provider value={createVersionedValueMap({ 1: value })}>
|
||||
{children}
|
||||
<AnalyticsContext
|
||||
attributes={{
|
||||
...(entity ? { entityRef: stringifyEntityRef(entity) } : undefined),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AnalyticsContext>
|
||||
</NewEntityContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,8 +24,10 @@ describe('EntityLinksCard', () => {
|
||||
const createEntity = (links: EntityLink[] = []): Entity =>
|
||||
({
|
||||
metadata: {
|
||||
name: 'mock',
|
||||
links,
|
||||
},
|
||||
kind: 'MockKind',
|
||||
} as Entity);
|
||||
|
||||
const createLink = ({
|
||||
|
||||
@@ -46,7 +46,9 @@ describe('EntitySwitch', () => {
|
||||
|
||||
const rendered = render(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={{ kind: 'component' } as Entity}>
|
||||
<EntityProvider
|
||||
entity={{ metadata: { name: 'mock' }, kind: 'component' } as Entity}
|
||||
>
|
||||
{content}
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
@@ -58,7 +60,9 @@ describe('EntitySwitch', () => {
|
||||
|
||||
rendered.rerender(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={{ kind: 'template' } as Entity}>
|
||||
<EntityProvider
|
||||
entity={{ metadata: { name: 'mock' }, kind: 'template' } as Entity}
|
||||
>
|
||||
{content}
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
@@ -70,7 +74,9 @@ describe('EntitySwitch', () => {
|
||||
|
||||
rendered.rerender(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={{ kind: 'derp' } as Entity}>
|
||||
<EntityProvider
|
||||
entity={{ metadata: { name: 'mock' }, kind: 'derp' } as Entity}
|
||||
>
|
||||
{content}
|
||||
</EntityProvider>
|
||||
</Wrapper>,
|
||||
@@ -94,7 +100,7 @@ describe('EntitySwitch', () => {
|
||||
});
|
||||
|
||||
it('should switch child when filters switch', () => {
|
||||
const entity = { kind: 'component' } as Entity;
|
||||
const entity = { metadata: { name: 'mock' }, kind: 'component' } as Entity;
|
||||
|
||||
const rendered = render(
|
||||
<Wrapper>
|
||||
@@ -126,7 +132,7 @@ describe('EntitySwitch', () => {
|
||||
});
|
||||
|
||||
it('should switch with async condition that is true', async () => {
|
||||
const entity = { kind: 'component' } as Entity;
|
||||
const entity = { metadata: { name: 'mock' }, kind: 'component' } as Entity;
|
||||
|
||||
const shouldRender = () => Promise.resolve(true);
|
||||
const rendered = render(
|
||||
@@ -145,7 +151,7 @@ describe('EntitySwitch', () => {
|
||||
});
|
||||
|
||||
it('should switch with sync condition that is false', async () => {
|
||||
const entity = { kind: 'component' } as Entity;
|
||||
const entity = { metadata: { name: 'mock' }, kind: 'component' } as Entity;
|
||||
|
||||
const shouldRender = () => Promise.resolve(false);
|
||||
const rendered = render(
|
||||
@@ -164,7 +170,7 @@ describe('EntitySwitch', () => {
|
||||
});
|
||||
|
||||
it('should switch with sync condition that throws', async () => {
|
||||
const entity = { kind: 'component' } as Entity;
|
||||
const entity = { metadata: { name: 'mock' }, kind: 'component' } as Entity;
|
||||
|
||||
const shouldRender = () => Promise.reject();
|
||||
const rendered = render(
|
||||
|
||||
@@ -36,7 +36,9 @@ describe('GoCdArtifactsComponent', () => {
|
||||
baseUrl: 'gocd.baseurl.com',
|
||||
},
|
||||
});
|
||||
const entityValue = { entity: { metadata: {} } as Entity };
|
||||
const entityValue = {
|
||||
entity: { metadata: { name: 'mock' }, kind: 'MockKind' } as Entity,
|
||||
};
|
||||
|
||||
const renderComponent = () =>
|
||||
renderWithEffects(
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
errorApiRef,
|
||||
useApi,
|
||||
featureFlagsApiRef,
|
||||
useAnalytics,
|
||||
useRouteRefParams,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { FormProps, IChangeEvent, UiSchema, withTheme } from '@rjsf/core';
|
||||
import { Theme as MuiTheme } from '@rjsf/material-ui';
|
||||
@@ -37,6 +39,7 @@ import { Content, StructuredMetadataTable } from '@backstage/core-components';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import * as fieldOverrides from './FieldOverrides';
|
||||
import { LayoutOptions } from '../../layouts';
|
||||
import { selectedTemplateRouteRef } from '../../routes';
|
||||
|
||||
const Form = withTheme(MuiTheme);
|
||||
type Step = {
|
||||
@@ -123,6 +126,8 @@ export const MultistepJsonForm = (props: Props) => {
|
||||
finishButtonLabel,
|
||||
layouts,
|
||||
} = props;
|
||||
const { templateName } = useRouteRefParams(selectedTemplateRouteRef);
|
||||
const analytics = useAnalytics();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [disableButtons, setDisableButtons] = useState(false);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
@@ -171,7 +176,9 @@ export const MultistepJsonForm = (props: Props) => {
|
||||
onReset();
|
||||
};
|
||||
const handleNext = () => {
|
||||
setActiveStep(Math.min(activeStep + 1, steps.length));
|
||||
const stepNum = Math.min(activeStep + 1, steps.length);
|
||||
setActiveStep(stepNum);
|
||||
analytics.captureEvent('click', `Next Step (${stepNum})`);
|
||||
};
|
||||
const handleBack = () => setActiveStep(Math.max(activeStep - 1, 0));
|
||||
const handleCreate = async () => {
|
||||
@@ -182,6 +189,7 @@ export const MultistepJsonForm = (props: Props) => {
|
||||
setDisableButtons(true);
|
||||
try {
|
||||
await onFinish();
|
||||
analytics.captureEvent('create', formData.name || `new ${templateName}`);
|
||||
} catch (err) {
|
||||
errorApi.post(err);
|
||||
} finally {
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
|
||||
import {
|
||||
MockAnalyticsApi,
|
||||
renderInTestApp,
|
||||
TestApiRegistry,
|
||||
} from '@backstage/test-utils';
|
||||
import { act, fireEvent, within } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router';
|
||||
@@ -24,6 +28,7 @@ import { TemplatePage } from './TemplatePage';
|
||||
import {
|
||||
featureFlagsApiRef,
|
||||
FeatureFlagsApi,
|
||||
analyticsApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
@@ -57,6 +62,8 @@ const featureFlagsApiMock: jest.Mocked<FeatureFlagsApi> = {
|
||||
|
||||
const errorApiMock = { post: jest.fn(), error$: jest.fn() };
|
||||
|
||||
const analyticsMock = new MockAnalyticsApi();
|
||||
|
||||
const schemaMockValue = {
|
||||
title: 'my-schema',
|
||||
steps: [
|
||||
@@ -105,6 +112,7 @@ const apis = TestApiRegistry.from(
|
||||
[scaffolderApiRef, scaffolderApiMock],
|
||||
[errorApiRef, errorApiMock],
|
||||
[featureFlagsApiRef, featureFlagsApiMock],
|
||||
[analyticsApiRef, analyticsMock],
|
||||
);
|
||||
|
||||
describe('TemplatePage', () => {
|
||||
@@ -158,6 +166,66 @@ describe('TemplatePage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('captures expected analytics events', async () => {
|
||||
scaffolderApiMock.scaffold.mockResolvedValue({ taskId: 'xyz' });
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
|
||||
title: 'schema-4-analytics',
|
||||
steps: [
|
||||
{
|
||||
title: 'Fill in some steps',
|
||||
schema: {
|
||||
properties: {
|
||||
name: {
|
||||
title: 'Name',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { findByLabelText, findByText } = await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<TemplatePage />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Fill out the name field
|
||||
expect(await findByText('Fill in some steps')).toBeInTheDocument();
|
||||
fireEvent.change(await findByLabelText('Name', { exact: false }), {
|
||||
target: { value: 'expected-name' },
|
||||
});
|
||||
|
||||
// Go to the final page
|
||||
fireEvent.click(await findByText('Next step'));
|
||||
expect(await findByText('Reset')).toBeInTheDocument();
|
||||
|
||||
// Create the software
|
||||
await act(async () => {
|
||||
fireEvent.click(await findByText('Create'));
|
||||
});
|
||||
|
||||
// The "Next Step" button should have fired an event
|
||||
expect(analyticsMock.getEvents()[0]).toMatchObject({
|
||||
action: 'click',
|
||||
subject: 'Next Step (1)',
|
||||
context: { entityRef: 'template:default/test' },
|
||||
});
|
||||
|
||||
// And the "Create" button should have fired an event
|
||||
expect(analyticsMock.getEvents()[1]).toMatchObject({
|
||||
action: 'create',
|
||||
subject: 'expected-name',
|
||||
context: { entityRef: 'template:default/test' },
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates away if no template was loaded', async () => {
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue(
|
||||
undefined as any,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createValidator } from './createValidator';
|
||||
|
||||
import { Content, Header, InfoCard, Page } from '@backstage/core-components';
|
||||
import {
|
||||
AnalyticsContext,
|
||||
errorApiRef,
|
||||
useApi,
|
||||
useApiHolder,
|
||||
@@ -129,41 +130,43 @@ export const TemplatePage = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
pageTitleOverride="Create a New Component"
|
||||
title="Create a New Component"
|
||||
subtitle="Create new software components using standard templates"
|
||||
/>
|
||||
<Content>
|
||||
{loading && <LinearProgress data-testid="loading-progress" />}
|
||||
{schema && (
|
||||
<InfoCard
|
||||
title={schema.title}
|
||||
noPadding
|
||||
titleTypographyProps={{ component: 'h2' }}
|
||||
>
|
||||
<MultistepJsonForm
|
||||
formData={formState}
|
||||
fields={customFieldComponents}
|
||||
onChange={handleChange}
|
||||
onReset={handleFormReset}
|
||||
onFinish={handleCreate}
|
||||
layouts={layouts}
|
||||
steps={schema.steps.map(step => {
|
||||
return {
|
||||
...step,
|
||||
validate: createValidator(
|
||||
step.schema,
|
||||
customFieldValidators,
|
||||
{ apiHolder },
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</InfoCard>
|
||||
)}
|
||||
</Content>
|
||||
</Page>
|
||||
<AnalyticsContext attributes={{ entityRef: templateRef }}>
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
pageTitleOverride="Create a New Component"
|
||||
title="Create a New Component"
|
||||
subtitle="Create new software components using standard templates"
|
||||
/>
|
||||
<Content>
|
||||
{loading && <LinearProgress data-testid="loading-progress" />}
|
||||
{schema && (
|
||||
<InfoCard
|
||||
title={schema.title}
|
||||
noPadding
|
||||
titleTypographyProps={{ component: 'h2' }}
|
||||
>
|
||||
<MultistepJsonForm
|
||||
formData={formState}
|
||||
fields={customFieldComponents}
|
||||
onChange={handleChange}
|
||||
onReset={handleFormReset}
|
||||
onFinish={handleCreate}
|
||||
layouts={layouts}
|
||||
steps={schema.steps.map(step => {
|
||||
return {
|
||||
...step,
|
||||
validate: createValidator(
|
||||
step.schema,
|
||||
customFieldValidators,
|
||||
{ apiHolder },
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</InfoCard>
|
||||
)}
|
||||
</Content>
|
||||
</Page>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,10 @@ describe('TodoList', () => {
|
||||
offset: 0,
|
||||
}),
|
||||
};
|
||||
const mockEntity = { metadata: { name: 'mock' } } as Entity;
|
||||
const mockEntity = {
|
||||
metadata: { name: 'mock' },
|
||||
kind: 'MockKind',
|
||||
} as Entity;
|
||||
|
||||
const rendered = await renderWithEffects(
|
||||
<TestApiProvider apis={[[todoApiRef, mockApi]]}>
|
||||
|
||||
Reference in New Issue
Block a user