Introduce basic scaffolder instrumentation

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2022-10-13 14:30:34 +02:00
parent a889314692
commit 4830a3569f
5 changed files with 132 additions and 44 deletions
+8
View File
@@ -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.
+7 -6
View File
@@ -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
@@ -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>
);
};