Merge pull request #23401 from backstage/roi/scaffolder-metadata

Standard entity metadata to help measure Scaffolder ROI
This commit is contained in:
Ben Lambert
2024-04-03 10:38:11 +02:00
committed by GitHub
19 changed files with 463 additions and 12 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Added a "create something similar" button to the `<AboutCard>` that is visible and links to the scaffolder template corresponding to the entity's `backstage.io/source-template` annotation, if present.
@@ -0,0 +1,7 @@
---
'@backstage/plugin-scaffolder-react': patch
---
The `value` sent on the `create` analytics event (fired when a Scaffolder template is executed) is now set to the number of minutes saved by executing the template. This value is derived from the `backstage.io/time-saved` annotation on the template entity, if available.
Note: the `create` event is now captured in the `<Workflow>` component. If you are directly making use of the alpha-exported `<Stepper>` component, an analytics `create` event will no longer be captured on your behalf.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
The `catalog:write` action now automatically adds a `backstage.io/template-source` annotation, indicating which Scaffolder template was used to create the entity.
@@ -659,6 +659,8 @@ metadata:
name: v1beta2-demo
title: Test Action template
description: scaffolder v1beta2 template demo
annotations:
backstage.io/time-saved: PT4H
spec:
owner: backstage/techdocs-core
type: service
@@ -736,6 +738,16 @@ A list of strings that can be associated with the template, e.g.
This list will also be used in the frontend to display to the user so you can
potentially search and group templates by these tags.
### `metadata.annotations.[backstage.io/time-saved]` [optional]
An ISO 8601 duration representing the approximate amount of time saved when
someone uses this template (e.g. `PT8H` to mean "8 hours saved" or `PT15M` to
mean "15 minutes saved").
Can be used in combination with the `backstage.io/source-template` annotation,
or analytics data, to calculate how much time has been saved through the use
of the Scaffolder plugin.
### `spec.type` [required]
The type of component created by the template, e.g. `website`. This is used for
@@ -146,6 +146,25 @@ repository itself. If the URL points to a folder, it is important that it is
suffixed with a `'/'` in order for relative path resolution to work
consistently.
### backstage.io/source-template
```yaml
# Example:
metadata:
annotations:
backstage.io/source-template: template:default/create-react-app-template
```
Represents the entity ref of the Scaffolder template that was originally used
to create the given entity. Useful to power "create something similar"
experiences, as well as to track adherence to software standards across the
Catalog.
Note that this value is only automatically added to an entity when the
`catalog:write` action is used to create the `catalog-info.yaml` file. It is
otherwise the template author's responsibility to ensure that any entity
definition included as part of the template contains this annotation.
### jenkins.io/job-full-name
```yaml
+1 -1
View File
@@ -64,7 +64,7 @@ installed, may be captured.
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `navigate` | The URL of the page that was navigated to. | Fired immediately when route location changes (unless associated plugin/route data is ambiguous, in which case the event is fired after plugin/route data becomes known, immediately before the next event or document unload). The parameters of the current route will be included as attributes. |
| `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`). |
| `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`). The `value` represents the number of minutes saved by running the template (based on the template's `backstage.io/time-saved` annotation, if available). |
| `search` | The search term entered in any search bar component. | The context holds `searchTypes`, representing `types` constraining the search. The `value` represents the total number of search results for the query. This may not be visible if the permission framework is being used. |
| `discover` | The title of the search result that was clicked on | The `value` is the result rank. A `to` attribute is also provided. |
| `not-found` | The path of the resource that resulted in a not found page | Fired by at least TechDocs. |
@@ -29,6 +29,7 @@ metadata:
title: Alerts
icon: alert
annotations:
backstage.io/source-template: template:default/springboot-template
backstage.io/linguist: 'https://github.com/backstage/backstage/tree/master/plugins/playlist'
spec:
type: service
@@ -3,6 +3,8 @@ kind: Component
metadata:
name: www-artist
description: Artist main website
annotations:
backstage.io/source-template: template:default/react-ssr-template
spec:
type: website
lifecycle: production
@@ -31,7 +31,7 @@ import { AboutCard } from './AboutCard';
import { ConfigReader } from '@backstage/core-app-api';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import React from 'react';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
@@ -44,6 +44,7 @@ describe('<AboutCard />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntityByRef: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByRef: jest.fn(),
@@ -266,6 +267,138 @@ describe('<AboutCard />', () => {
expect(screen.getByText('View Source').closest('a')).toBeNull();
});
it('renders "create something similar" button', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/source-template': 'template:default/foo-template',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
// Return any valid value to indicate access to the template is okay.
catalogApi.getEntityByRef.mockImplementation(async ref => {
expect(ref).toBe('template:default/foo-template');
return entity;
});
await renderInTestApp(
<TestApiProvider
apis={[
[
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
),
],
[catalogApiRef, catalogApi],
[permissionApiRef, {}],
]}
>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/create/templates/:namespace/:templateName':
createFromTemplateRouteRef,
},
},
);
await waitFor(() => {
const createSimilarLink = screen
.getByTitle('Create something similar')
.closest('a');
expect(createSimilarLink).toHaveAttribute(
'href',
'/create/templates/default/foo-template',
);
});
});
it('should not render "create something similar" button if template does not exist', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/source-template': 'template:default/gone-template',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
// Return any valid value to indicate access to the template is okay.
catalogApi.getEntityByRef.mockImplementation(async ref => {
expect(ref).toBe('template:default/gone-template');
return undefined;
});
await renderInTestApp(
<TestApiProvider
apis={[
[
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
),
],
[catalogApiRef, catalogApi],
[permissionApiRef, {}],
]}
>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/create/templates/:namespace/:templateName':
createFromTemplateRouteRef,
},
},
);
expect(
screen.queryByTitle('Create something similar'),
).not.toBeInTheDocument();
});
it.each([
'url:https://backstage.io/catalog-info.yaml',
'file:../../catalog-info.yaml',
@@ -27,6 +27,7 @@ import Divider from '@material-ui/core/Divider';
import IconButton from '@material-ui/core/IconButton';
import { makeStyles } from '@material-ui/core/styles';
import {
AppIcon,
HeaderIconLinkRow,
IconLinkVerticalProps,
InfoCardVariants,
@@ -60,6 +61,7 @@ import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { parseEntityRef } from '@backstage/catalog-model';
import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha';
import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha';
import { useSourceTemplateCompoundEntityRef } from './hooks';
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
@@ -108,6 +110,7 @@ export function AboutCard(props: AboutCardProps) {
const errorApi = useApi(errorApiRef);
const viewTechdocLink = useRouteRef(viewTechDocRouteRef);
const templateRoute = useRouteRef(createFromTemplateRouteRef);
const sourceTemplateRef = useSourceTemplateCompoundEntityRef(entity);
const { allowed: canRefresh } = useEntityPermission(
catalogEntityRefreshPermission,
);
@@ -236,6 +239,18 @@ export function AboutCard(props: AboutCardProps) {
>
<EditIcon />
</IconButton>
{sourceTemplateRef && templateRoute && (
<IconButton
component={Link}
title="Create something similar"
to={templateRoute({
namespace: sourceTemplateRef.namespace,
templateName: sourceTemplateRef.name,
})}
>
<AppIcon id="scaffolder" />
</IconButton>
)}
</>
}
subheader={<HeaderIconLinkRow links={subHeaderLinks} />}
@@ -0,0 +1,56 @@
/*
* Copyright 2020 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 {
CompoundEntityRef,
Entity,
parseEntityRef,
} from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import useAsync from 'react-use/lib/useAsync';
// todo: should this be a constant in a scaffolder package?
const SOURCE_TEMPLATE_ANNOTATION = 'backstage.io/source-template';
/**
* Returns the compound entity ref of the source template that was used to
* create this entity (assuming that it still exists and the user has access
* to it).
*/
export const useSourceTemplateCompoundEntityRef = (entity: Entity) => {
const catalogApi = useApi(catalogApiRef);
const { value: sourceTemplateRef } = useAsync(async () => {
const refCandidate =
entity.metadata.annotations?.[SOURCE_TEMPLATE_ANNOTATION];
let compoundRefCandidate: CompoundEntityRef | undefined;
if (!refCandidate) {
return undefined;
}
try {
// Check for access and that this template still exists.
const template = await catalogApi.getEntityByRef(refCandidate);
compoundRefCandidate = parseEntityRef(refCandidate);
return template !== undefined ? compoundRefCandidate : undefined;
} catch {
return undefined;
}
}, [catalogApi, entity]);
return sourceTemplateRef;
};
@@ -94,4 +94,34 @@ describe('catalog:write', () => {
yaml.stringify(entity),
);
});
it('should add backstage.io/source-template if provided', async () => {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'n',
namespace: 'ns',
annotations: {},
},
spec: {},
};
await action.handler({
...mockContext,
templateInfo: { entityRef: 'template:default/test-skeleton' },
input: { entity },
});
const expectedEntity = JSON.parse(JSON.stringify(entity));
expectedEntity.metadata.annotations = {
'backstage.io/source-template': 'template:default/test-skeleton',
};
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(mockContext.workspacePath, 'catalog-info.yaml'),
yaml.stringify(expectedEntity),
);
});
});
@@ -51,11 +51,25 @@ export function createCatalogWriteAction() {
async handler(ctx) {
ctx.logger.info(`Writing catalog-info.yaml`);
const { filePath, entity } = ctx.input;
const entityRef = ctx.templateInfo?.entityRef;
const path = filePath ?? 'catalog-info.yaml';
await fs.writeFile(
resolveSafeChildPath(ctx.workspacePath, path),
yaml.stringify(entity),
yaml.stringify({
...entity,
metadata: {
...entity.metadata,
...(entityRef
? {
annotations: {
...entity.metadata.annotations,
'backstage.io/source-template': entityRef,
},
}
: undefined),
},
}),
);
},
});
@@ -75,6 +75,11 @@ const useStyles = makeStyles(theme => ({
export type StepperProps = {
manifest: TemplateParameterSchema;
extensions: FieldExtensionOptions<any, any>[];
/**
* @deprecated This was only ever used for analytics tracking purposes, which
* is now handled in the `<Workflow />` component. Passing it in will have no
* effect.
*/
templateName?: string;
formProps?: FormProps;
initialState?: Record<string, JsonValue>;
@@ -147,10 +152,7 @@ export const Stepper = (stepperProps: StepperProps) => {
const handleCreate = useCallback(() => {
props.onCreate(formState);
const name =
typeof formState.name === 'string' ? formState.name : undefined;
analytics.captureEvent('create', name ?? props.templateName ?? 'unknown');
}, [props, formState, analytics]);
}, [props, formState]);
const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts });
@@ -25,6 +25,7 @@ import React from 'react';
import { Workflow } from './Workflow';
import { analyticsApiRef } from '@backstage/core-plugin-api';
import { ScaffolderApi, scaffolderApiRef } from '../../../api';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
cancelTask: jest.fn(),
@@ -36,10 +37,14 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
listActions: jest.fn(),
listTasks: jest.fn(),
};
const catalogApiMock: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
} as any;
const analyticsMock = new MockAnalyticsApi();
const apis = TestApiRegistry.from(
[scaffolderApiRef, scaffolderApiMock],
[catalogApiRef, catalogApiMock],
[analyticsApiRef, analyticsMock],
);
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import {
Content,
InfoCard,
@@ -23,12 +23,14 @@ import {
} from '@backstage/core-components';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { makeStyles } from '@material-ui/core';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { errorApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api';
import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema';
import { Stepper, type StepperProps } from '../Stepper/Stepper';
import { SecretsContextProvider } from '../../../secrets/SecretsContext';
import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProperties';
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved';
import { JsonValue } from '@backstage/types';
const useStyles = makeStyles({
markdown: {
@@ -68,9 +70,10 @@ export type WorkflowProps = {
* @alpha
*/
export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
const { title, description, namespace, templateName, ...props } =
const { title, description, namespace, templateName, onCreate, ...props } =
workflowProps;
const analytics = useAnalytics();
const styles = useStyles();
const templateRef = stringifyEntityRef({
kind: 'Template',
@@ -84,6 +87,21 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
const sortedManifest = useFilteredSchemaProperties(manifest);
const minutesSaved = useTemplateTimeSavedMinutes(templateRef);
const workflowOnCreate = useCallback(
async (formState: Record<string, JsonValue>) => {
onCreate(formState);
const name =
typeof formState.name === 'string' ? formState.name : undefined;
analytics.captureEvent('create', name ?? templateName ?? 'unknown', {
value: minutesSaved,
});
},
[onCreate, analytics, templateName, minutesSaved],
);
useEffect(() => {
if (error) {
errorApi.post(new Error(`Failed to load template, ${error}`));
@@ -113,7 +131,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
>
<Stepper
manifest={sortedManifest}
templateName={templateName}
onCreate={workflowOnCreate}
{...props}
/>
</InfoCard>
@@ -0,0 +1,74 @@
/*
* Copyright 2024 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 { useTemplateTimeSavedMinutes } from './useTemplateTimeSaved';
import { renderHook, waitFor } from '@testing-library/react';
import { TestApiProvider } from '@backstage/test-utils';
import React from 'react';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
const getTemplateWithTimeSaved = (
timeSaved: string | undefined,
): TemplateEntityV1beta3 => ({
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 'test-fixture',
...(timeSaved
? {
annotations: {
'backstage.io/time-saved': timeSaved,
},
}
: undefined),
},
spec: {
type: 'test-fixture',
steps: [],
},
});
describe('useTemplateTimeSavedMinutes', () => {
it.each([
['PT2H', 120],
['P3D', 4320],
['2 hours', undefined],
[undefined, undefined],
])(
'should return the expected duration given "%s"',
async (given, expected) => {
const templateRef = 'template:default/happy-path';
const template = getTemplateWithTimeSaved(given);
const { result } = renderHook(
() => useTemplateTimeSavedMinutes(templateRef),
{
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<TestApiProvider
apis={[[catalogApiRef, { getEntityByRef: async () => template }]]}
>
{children}
</TestApiProvider>
),
},
);
await waitFor(() => {
expect(result.current).toEqual(expected);
});
},
);
});
@@ -0,0 +1,49 @@
/*
* Copyright 2024 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 { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Duration } from 'luxon';
import useAsync from 'react-use/lib/useAsync';
/**
* Returns the backstage.io/time-saved annotation (as a number of minutes) for
* a given template entity ref.
*/
export const useTemplateTimeSavedMinutes = (templateRef: string) => {
const catalogApi = useApi(catalogApiRef);
const { value: timeSavedMinutes } = useAsync(async () => {
const entity = await catalogApi.getEntityByRef(templateRef);
const timeSaved = entity?.metadata.annotations?.['backstage.io/time-saved'];
// This is not a valid template or the template has no time-saved value.
if (!entity || !timeSaved) {
return undefined;
}
const durationMs = Duration.fromISO(timeSaved).as('minutes');
// The time-saved annotation has an invalid value. Ignore.
if (Number.isNaN(durationMs)) {
return undefined;
}
return durationMs;
}, [catalogApi, templateRef]);
return timeSavedMinutes;
};
@@ -29,8 +29,9 @@ import {
} from '@backstage/plugin-scaffolder-react';
import { TemplateWizardPage } from './TemplateWizardPage';
import { rootRouteRef } from '../../routes';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { ANNOTATION_EDIT_URL } from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/catalog-client';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
jest.mock('react-router-dom', () => {
return {
@@ -59,6 +60,7 @@ const catalogApiMock: jest.Mocked<CatalogApi> = {
const analyticsMock = new MockAnalyticsApi();
const apis = TestApiRegistry.from(
[scaffolderApiRef, scaffolderApiMock],
[catalogApiRef, catalogApiMock],
[analyticsApiRef, analyticsMock],
[catalogApiRef, catalogApiMock],
);
@@ -70,6 +72,7 @@ const entityRefResponse = {
name: 'test',
annotations: {
[ANNOTATION_EDIT_URL]: 'http://localhost:3000',
'backstage.io/time-saved': 'PT2H',
},
},
spec: {
@@ -138,6 +141,7 @@ describe('TemplateWizardPage', () => {
action: 'create',
subject: 'expected-name',
context: { entityRef: 'template:default/test' },
value: 120,
});
});
describe('scaffolder page context menu', () => {