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/.changeset/analyze-software-exploration.md b/.changeset/analyze-software-exploration.md new file mode 100644 index 0000000000..f535f60127 --- /dev/null +++ b/.changeset/analyze-software-exploration.md @@ -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. diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index ccb4e5c5a7..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 @@ -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 diff --git a/plugins/badges/src/components/EntityBadgesDialog.test.tsx b/plugins/badges/src/components/EntityBadgesDialog.test.tsx index ea44eb20fc..5d0e7fe849 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.test.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.test.tsx @@ -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( { 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 }) => ( @@ -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 }) => ( + + + + ), + }); + + 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 }) => ( + + {}} + children={children} + /> + + ), + }); + + 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 }) => ( + + + + ), + }); + + result.current.captureEvent('test', 'value'); + + expect(analyticsSpy.getEvents()[0].context).not.toHaveProperty('entityRef'); + }); }); diff --git a/plugins/catalog-react/src/hooks/useEntity.tsx b/plugins/catalog-react/src/hooks/useEntity.tsx index f654cff777..84b7883c8a 100644 --- a/plugins/catalog-react/src/hooks/useEntity.tsx +++ b/plugins/catalog-react/src/hooks/useEntity.tsx @@ -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 ( - {children} + + {children} + ); }; diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx index 04ee1dc99e..8972cd2f9e 100644 --- a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx +++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx @@ -24,8 +24,10 @@ describe('EntityLinksCard', () => { const createEntity = (links: EntityLink[] = []): Entity => ({ metadata: { + name: 'mock', links, }, + kind: 'MockKind', } as Entity); const createLink = ({ diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 9588327c3b..4ee9d9c820 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -46,7 +46,9 @@ describe('EntitySwitch', () => { const rendered = render( - + {content} , @@ -58,7 +60,9 @@ describe('EntitySwitch', () => { rendered.rerender( - + {content} , @@ -70,7 +74,9 @@ describe('EntitySwitch', () => { rendered.rerender( - + {content} , @@ -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( @@ -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( diff --git a/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.test.tsx b/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.test.tsx index 7f16f40a6a..ebc3502901 100644 --- a/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.test.tsx +++ b/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.test.tsx @@ -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( 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 }, + ), + }; + })} + /> + + )} + + + ); }; diff --git a/plugins/todo/src/components/TodoList/TodoList.test.tsx b/plugins/todo/src/components/TodoList/TodoList.test.tsx index 2759fe40f8..15d4334610 100644 --- a/plugins/todo/src/components/TodoList/TodoList.test.tsx +++ b/plugins/todo/src/components/TodoList/TodoList.test.tsx @@ -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(