From 4830a3569f792a726c6853a17a75a2a773c094e1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 13 Oct 2022 14:30:34 +0200 Subject: [PATCH] Introduce basic scaffolder instrumentation Signed-off-by: Eric Peterson --- .changeset/analyze-software-creation.md | 8 ++ docs/plugins/analytics.md | 13 ++-- .../MultistepJsonForm/MultistepJsonForm.tsx | 10 ++- .../TemplatePage/TemplatePage.test.tsx | 70 ++++++++++++++++- .../components/TemplatePage/TemplatePage.tsx | 75 ++++++++++--------- 5 files changed, 132 insertions(+), 44 deletions(-) create mode 100644 .changeset/analyze-software-creation.md diff --git a/.changeset/analyze-software-creation.md b/.changeset/analyze-software-creation.md new file mode 100644 index 0000000000..312987d3d1 --- /dev/null +++ b/.changeset/analyze-software-creation.md @@ -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. diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index 0c2173de5c..46e1d354df 100644 --- a/docs/plugins/analytics.md +++ b/docs/plugins/analytics.md @@ -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 diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 8c71df67b6..7afbeb5a97 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -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 { diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index d35b561f2b..a48644e72d 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -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 = { 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( + + + , + { + 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, diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 9eba61b53b..87678d2a83 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -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 ( - -
- - {loading && } - {schema && ( - - { - return { - ...step, - validate: createValidator( - step.schema, - customFieldValidators, - { apiHolder }, - ), - }; - })} - /> - - )} - - + + +
+ + {loading && } + {schema && ( + + { + return { + ...step, + validate: createValidator( + step.schema, + customFieldValidators, + { apiHolder }, + ), + }; + })} + /> + + )} + + + ); };