Merge pull request #30229 from backstage/scaffolder/events

Scaffolder: add entityRef and taskId to analytics events
This commit is contained in:
Vincenzo Scamporlino
2025-06-16 15:10:53 +02:00
committed by GitHub
9 changed files with 141 additions and 100 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-react': minor
'@backstage/plugin-scaffolder': minor
---
Added information about the `entityRef` and `taskId` to the analytics events whenever is possible.
@@ -22,7 +22,7 @@ import {
} from '@backstage/core-components';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { TemplateCardProps, TemplateCard } from '../TemplateCard';
import { IconComponent } from '@backstage/core-plugin-api';
import { AnalyticsContext, IconComponent } from '@backstage/core-plugin-api';
/**
* The props for the {@link TemplateGroup} component.
@@ -69,12 +69,18 @@ export const TemplateGroup = (props: TemplateGroupProps) => {
{titleComponent}
<ItemCardGrid>
{templates.map(({ template, additionalLinks }) => (
<Card
<AnalyticsContext
attributes={{
entityRef: stringifyEntityRef(template),
}}
key={stringifyEntityRef(template)}
additionalLinks={additionalLinks}
template={template}
onSelected={onSelected}
/>
>
<Card
additionalLinks={additionalLinks}
template={template}
onSelected={onSelected}
/>
</AnalyticsContext>
))}
</ItemCardGrid>
</Content>
@@ -22,7 +22,7 @@ import {
} from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
import Typography from '@material-ui/core/Typography';
import { ComponentType, useCallback } from 'react';
import { ComponentType, useCallback, useEffect } from 'react';
import { TemplateGroup } from '../TemplateGroup/TemplateGroup';
@@ -58,12 +58,17 @@ export const TemplateGroups = (props: TemplateGroupsProps) => {
[onTemplateSelected],
);
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [error, errorApi]);
if (loading) {
return <Progress />;
}
if (error) {
errorApi.post(error);
return null;
}
@@ -97,13 +97,14 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
async (formState: Record<string, JsonValue>) => {
await onCreate(formState);
const name =
typeof formState.name === 'string' ? formState.name : undefined;
analytics.captureEvent('create', name ?? templateName ?? 'unknown', {
analytics.captureEvent('create', 'Task has been created', {
value: minutesSaved,
attributes: {
templateSteps: sortedManifest?.steps?.length ?? 0,
},
});
},
[onCreate, analytics, templateName, minutesSaved],
[onCreate, analytics, minutesSaved, sortedManifest],
);
useEffect(() => {
@@ -16,6 +16,7 @@
import cloneDeep from 'lodash/cloneDeep';
import { useApi, featureFlagsApiRef } from '@backstage/core-plugin-api';
import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react';
import { useMemo } from 'react';
/**
* Returns manifest of software templates with steps without a featureFlag tag.
@@ -28,49 +29,51 @@ export const useFilteredSchemaProperties = (
const featureFlagKey = 'backstage:featureFlag';
const featureFlagApi = useApi(featureFlagsApiRef);
if (!manifest) {
return undefined;
}
const filteredSteps = manifest?.steps
.filter(step => {
const featureFlag = step.schema[featureFlagKey];
return (
typeof featureFlag !== 'string' || featureFlagApi.isActive(featureFlag)
);
})
.map(step => {
const filteredStep = cloneDeep(step);
const removedPropertyKeys: Array<string> = [];
if (filteredStep.schema.properties) {
filteredStep.schema.properties = Object.fromEntries(
Object.entries(filteredStep.schema.properties).filter(
([key, value]) => {
if (value[featureFlagKey]) {
if (featureFlagApi.isActive(value[featureFlagKey])) {
return true;
}
removedPropertyKeys.push(key);
return false;
}
return true;
},
),
return useMemo(() => {
if (!manifest) {
return undefined;
}
const filteredSteps = manifest?.steps
.filter(step => {
const featureFlag = step.schema[featureFlagKey];
return (
typeof featureFlag !== 'string' ||
featureFlagApi.isActive(featureFlag)
);
})
.map(step => {
const filteredStep = cloneDeep(step);
const removedPropertyKeys: Array<string> = [];
if (filteredStep.schema.properties) {
filteredStep.schema.properties = Object.fromEntries(
Object.entries(filteredStep.schema.properties).filter(
([key, value]) => {
if (value[featureFlagKey]) {
if (featureFlagApi.isActive(value[featureFlagKey])) {
return true;
}
// remove the feature flag property key from required if they are not active
filteredStep.schema.required = Array.isArray(
filteredStep.schema.required,
)
? filteredStep.schema.required?.filter(
r => !removedPropertyKeys.includes(r as string),
)
: filteredStep.schema.required;
}
removedPropertyKeys.push(key);
return false;
}
return true;
},
),
);
return filteredStep;
});
// remove the feature flag property key from required if they are not active
filteredStep.schema.required = Array.isArray(
filteredStep.schema.required,
)
? filteredStep.schema.required?.filter(
r => !removedPropertyKeys.includes(r as string),
)
: filteredStep.schema.required;
}
return { ...manifest, steps: filteredSteps };
return filteredStep;
});
return { ...manifest, steps: filteredSteps };
}, [manifest, featureFlagApi]);
};
@@ -21,7 +21,7 @@ import {
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import { fireEvent, waitFor } from '@testing-library/react';
import {
ScaffolderApi,
scaffolderApiRef,
@@ -127,14 +127,10 @@ describe('TemplateWizardPage', () => {
});
// Go to the final page
await act(async () => {
fireEvent.click(await findByRole('button', { name: 'Review' }));
});
fireEvent.click(await findByRole('button', { name: 'Review' }));
// Create the software
await act(async () => {
fireEvent.click(await findByRole('button', { name: 'Create' }));
});
fireEvent.click(await findByRole('button', { name: 'Create' }));
// The "Next Step" button should have fired an event
expect(analyticsApi.captureEvent).toHaveBeenCalledWith(
@@ -148,15 +144,20 @@ describe('TemplateWizardPage', () => {
);
// And the "Create" button should have fired an event
expect(analyticsApi.captureEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: 'create',
subject: 'expected-name',
context: expect.objectContaining({
entityRef: 'template:default/test',
await waitFor(() =>
expect(analyticsApi.captureEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: 'create',
subject: 'Task has been created',
attributes: {
templateSteps: 1,
},
context: expect.objectContaining({
entityRef: 'template:default/test',
}),
value: 120,
}),
value: 120,
}),
),
);
});
@@ -26,6 +26,7 @@ import MoreVert from '@material-ui/icons/MoreVert';
import { SyntheticEvent, useState } from 'react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { Link } from '@backstage/core-components';
const useStyles = makeStyles(theme => ({
button: {
@@ -82,7 +83,7 @@ export function TemplateWizardPageContextMenu(
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuList>
<MenuItem onClick={() => window.open(editUrl, '_blank')}>
<MenuItem component={Link} to={editUrl}>
<ListItemIcon>
<Edit fontSize="small" />
</ListItemIcon>
@@ -21,7 +21,6 @@ import MenuItem from '@material-ui/core/MenuItem';
import MenuList from '@material-ui/core/MenuList';
import Popover from '@material-ui/core/Popover';
import { makeStyles, Theme, useTheme } from '@material-ui/core/styles';
import { useAsync } from '@react-hookz/web';
import Cancel from '@material-ui/icons/Cancel';
import Repeat from '@material-ui/icons/Repeat';
import Replay from '@material-ui/icons/Replay';
@@ -29,11 +28,8 @@ import Toc from '@material-ui/icons/Toc';
import ControlPointIcon from '@material-ui/icons/ControlPoint';
import MoreVert from '@material-ui/icons/MoreVert';
import { SyntheticEvent, useState } from 'react';
import { useAnalytics, useApi } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import { usePermission } from '@backstage/plugin-permission-react';
import {
taskCancelPermission,
taskReadPermission,
taskCreatePermission,
} from '@backstage/plugin-scaffolder-common/alpha';
@@ -50,7 +46,8 @@ type ContextMenuProps = {
onStartOver?: () => void;
onToggleLogs?: (state: boolean) => void;
onToggleButtonBar?: (state: boolean) => void;
taskId?: string;
isCancelButtonDisabled: boolean;
onCancel: () => void;
};
const useStyles = makeStyles<Theme, { fontColor: string }>(() => ({
@@ -70,27 +67,13 @@ export const ContextMenu = (props: ContextMenuProps) => {
onStartOver,
onToggleLogs,
onToggleButtonBar,
taskId,
} = props;
const { getPageTheme } = useTheme();
const pageTheme = getPageTheme({ themeId: 'website' });
const classes = useStyles({ fontColor: pageTheme.fontColor });
const scaffolderApi = useApi(scaffolderApiRef);
const analytics = useAnalytics();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const { t } = useTranslationRef(scaffolderTranslationRef);
const [{ status: cancelStatus }, { execute: cancel }] = useAsync(async () => {
if (taskId) {
analytics.captureEvent('cancelled', 'Template has been cancelled');
await scaffolderApi.cancelTask(taskId);
}
});
const { allowed: canCancelTask } = usePermission({
permission: taskCancelPermission,
});
const { allowed: canReadTask } = usePermission({
permission: taskReadPermission,
});
@@ -171,12 +154,8 @@ export const ContextMenu = (props: ContextMenuProps) => {
</MenuItem>
)}
<MenuItem
onClick={cancel}
disabled={
!cancelEnabled ||
cancelStatus !== 'not-executed' ||
!canCancelTask
}
onClick={props.onCancel}
disabled={props.isCancelButtonDisabled}
data-testid="cancel-task"
>
<ListItemIcon>
@@ -32,7 +32,12 @@ import {
useTaskEventStream,
} from '@backstage/plugin-scaffolder-react';
import { selectedTemplateRouteRef } from '../../routes';
import { useAnalytics, useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
AnalyticsContext,
useAnalytics,
useApi,
useRouteRef,
} from '@backstage/core-plugin-api';
import qs from 'qs';
import { ContextMenu } from './ContextMenu';
import {
@@ -51,6 +56,7 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../translation';
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
import { default as reactUseAsync } from 'react-use/esm/useAsync';
import { stringifyEntityRef } from '@backstage/catalog-model';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -82,6 +88,36 @@ export const OngoingTask = (props: {
}>;
}) => {
// todo(blam): check that task Id actually exists, and that it's valid. otherwise redirect to something more useful.
const { taskId } = useParams();
const taskStream = useTaskEventStream(taskId!);
const { namespace, name } =
taskStream.task?.spec.templateInfo?.entity?.metadata ?? {};
return (
<AnalyticsContext
attributes={{
entityRef:
name &&
stringifyEntityRef({
kind: 'template',
namespace,
name,
}),
taskId,
}}
>
<Page themeId="website">
<OngoingTaskContent {...props} />
</Page>
</AnalyticsContext>
);
};
function OngoingTaskContent(props: {
TemplateOutputsComponent?: ComponentType<{
output?: ScaffolderTaskOutput;
}>;
}) {
const { taskId } = useParams();
const templateRouteRef = useRouteRef(selectedTemplateRouteRef);
const navigate = useNavigate();
@@ -183,7 +219,7 @@ export const OngoingTask = (props: {
templateRouteRef,
]);
const [{ status: _ }, { execute: triggerRetry }] = useAsync(async () => {
const [, { execute: triggerRetry }] = useAsync(async () => {
if (taskId) {
analytics.captureEvent('retried', 'Template has been retried');
await scaffolderApi.retry?.(taskId);
@@ -202,9 +238,11 @@ export const OngoingTask = (props: {
const Outputs = props.TemplateOutputsComponent ?? DefaultTemplateOutputs;
const cancelEnabled = !(taskStream.cancelled || taskStream.completed);
const isCancelButtonDisabled =
!cancelEnabled || cancelStatus !== 'not-executed' || !canCancelTask;
return (
<Page themeId="website">
<>
<Header
pageTitleOverride={
presentation
@@ -231,7 +269,8 @@ export const OngoingTask = (props: {
onRetry={triggerRetry}
onToggleLogs={setLogVisibleState}
onToggleButtonBar={setButtonBarVisibleState}
taskId={taskId}
onCancel={triggerCancel}
isCancelButtonDisabled={isCancelButtonDisabled}
/>
</Header>
<Content className={classes.contentWrapper}>
@@ -316,6 +355,6 @@ export const OngoingTask = (props: {
</Paper>
) : null}
</Content>
</Page>
</>
);
};
}