Add DORA metrics insights to GoCD builds page

Signed-off-by: Julio Zynger <julio.zynger@soundcloud.com>
This commit is contained in:
Julio Zynger
2022-03-30 18:04:02 +02:00
parent 9b0a528b18
commit 4c6c2b2918
7 changed files with 702 additions and 18 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-gocd': patch
---
Add DORA metrics insights to GoCD builds page
Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 192 KiB

+17
View File
@@ -101,6 +101,23 @@ export enum GoCdBuildResultStatus {
pending,
}
export const toBuildResultStatus = (status: string): GoCdBuildResultStatus => {
switch (status.toLocaleLowerCase('en-US')) {
case 'passed':
return GoCdBuildResultStatus.successful;
case 'failed':
return GoCdBuildResultStatus.error;
case 'aborted':
return GoCdBuildResultStatus.aborted;
case 'building':
return GoCdBuildResultStatus.running;
case 'pending':
return GoCdBuildResultStatus.pending;
default:
return GoCdBuildResultStatus.aborted;
}
};
export interface GoCdBuildStageResult {
status: GoCdBuildResultStatus;
text: string;
@@ -27,6 +27,7 @@ import { useApi, configApiRef } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { gocdApiRef } from '../../plugin';
import { GoCdBuildsTable } from '../GoCdBuildsTable/GoCdBuildsTable';
import { GoCdBuildsInsights } from '../GoCdBuildsInsights/GoCdBuildsInsights';
import { GoCdApiError, PipelineHistory } from '../../api/gocdApi.model';
import { Item, Select } from '../Select';
@@ -120,6 +121,11 @@ export const GoCdBuildsComponent = (): JSX.Element => {
items={getSelectionItems(rawPipelines)}
/>
</ContentHeader>
<GoCdBuildsInsights
pipelineHistory={pipelineHistory}
loading={loading}
error={error}
/>
<GoCdBuildsTable
goCdBaseUrl={config.getString('gocd.baseUrl')}
pipelineHistory={pipelineHistory}
@@ -0,0 +1,417 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import {
GoCdBuildsInsights,
GoCdBuildsInsightsProps,
} from './GoCdBuildsInsights';
const minProps: GoCdBuildsInsightsProps = {
loading: false,
error: undefined,
pipelineHistory: {
_links: { next: { href: 'some-href' } },
pipelines: [
{
name: 'fortune',
counter: 278,
label: '278-8ab3',
natural_order: 278.0,
can_run: true,
preparing_to_schedule: false,
comment: null,
scheduled_date: 1643019617380,
build_cause: {
trigger_message: 'Forced by api',
trigger_forced: true,
approver: 'api',
material_revisions: [
{
changed: false,
material: {
name: 'git',
fingerprint: 'print',
type: 'Git',
description:
'URL: git@github.com:fake-org/fortune.git, Branch: master',
},
modifications: [
{
revision: '85d5debb5a93e5d43c7a695',
modified_time: 1642177395000,
user_name: 'User Name',
comment: 'This is a fake build',
email_address: null,
},
],
},
],
},
stages: [
{
result: 'Passed',
status: 'Passed',
rerun_of_counter: null,
name: 'init',
counter: '1',
scheduled: true,
approval_type: 'success',
approved_by: 'api',
operate_permission: true,
can_run: true,
jobs: [
{
name: 'package-publish',
scheduled_date: 1643019617380,
state: 'Completed',
result: 'Passed',
},
{
name: 'publish-deploy-replicas',
scheduled_date: 1643019617380,
state: 'Completed',
result: 'Passed',
},
{
name: 'lint',
scheduled_date: 1643019617380,
state: 'Completed',
result: 'Passed',
},
{
name: 'test-all',
scheduled_date: 1643019617380,
state: 'Completed',
result: 'Passed',
},
],
},
{
result: 'Passed',
status: 'Passed',
rerun_of_counter: null,
name: 'canary',
counter: '1',
scheduled: true,
approval_type: 'success',
approved_by: 'changes',
operate_permission: true,
can_run: true,
jobs: [
{
name: 'canary',
scheduled_date: 1643019690373,
state: 'Completed',
result: 'Passed',
},
],
},
{
result: 'Passed',
status: 'Passed',
rerun_of_counter: null,
name: 'deploy',
counter: '1',
scheduled: true,
approval_type: 'success',
approved_by: 'changes',
operate_permission: true,
can_run: true,
jobs: [
{
name: 'promote-and-deploy',
scheduled_date: 1643020668387,
state: 'Completed',
result: 'Passed',
},
],
},
],
},
{
name: 'fortune',
counter: 277,
label: '277-8ab3',
natural_order: 277.0,
can_run: true,
preparing_to_schedule: false,
comment: null,
scheduled_date: 1642177416721,
build_cause: {
trigger_message: 'modified by User Name',
trigger_forced: false,
approver: 'changes',
material_revisions: [
{
changed: true,
material: {
name: 'git',
fingerprint: '23feea5b59ef40cc7d43ac46bf08e3718',
type: 'Git',
description:
'URL: git@github.com:fake-org/fortune.git, Branch: master',
},
modifications: [
{
revision: '85d53e5d43c7a695',
modified_time: 1642177395000,
user_name: 'User Name',
comment: 'Update README',
email_address: null,
},
],
},
],
},
stages: [
{
result: 'Passed',
status: 'Passed',
rerun_of_counter: null,
name: 'init',
counter: '1',
scheduled: true,
approval_type: 'success',
approved_by: 'changes',
operate_permission: true,
can_run: true,
jobs: [
{
name: 'test-all',
scheduled_date: 1642177416721,
state: 'Completed',
result: 'Passed',
},
{
name: 'lint',
scheduled_date: 1642177416721,
state: 'Completed',
result: 'Passed',
},
{
name: 'publish-deploy-replicas',
scheduled_date: 1642377416721,
state: 'Completed',
result: 'Failed',
},
{
name: 'package-publish',
scheduled_date: 1642177416721,
state: 'Completed',
result: 'Passed',
},
],
},
{
result: 'Passed',
status: 'Passed',
rerun_of_counter: null,
name: 'canary',
counter: '1',
scheduled: true,
approval_type: 'success',
approved_by: 'changes',
operate_permission: true,
can_run: true,
jobs: [
{
name: 'canary',
scheduled_date: 1642177485728,
state: 'Completed',
result: 'Passed',
},
],
},
{
result: 'Passed',
status: 'Passed',
rerun_of_counter: null,
name: 'deploy',
counter: '1',
scheduled: true,
approval_type: 'success',
approved_by: 'changes',
operate_permission: true,
can_run: true,
jobs: [
{
name: 'promote-and-deploy',
scheduled_date: 1642178467479,
state: 'Completed',
result: 'Passed',
},
],
},
],
},
{
name: 'fortune',
counter: 277,
label: '277-8ab3',
natural_order: 277.0,
can_run: true,
preparing_to_schedule: false,
comment: null,
scheduled_date: 1642177416721,
build_cause: {
trigger_message: 'modified by User Name',
trigger_forced: false,
approver: 'changes',
material_revisions: [
{
changed: true,
material: {
name: 'git',
fingerprint: '23feea5b59ef40cc7d43ac46bf08e3718',
type: 'Git',
description:
'URL: git@github.com:fake-org/fortune.git, Branch: master',
},
modifications: [
{
revision: '85d53e5d43c7a695',
modified_time: 1642177395000,
user_name: 'User Name',
comment: 'Update README',
email_address: null,
},
],
},
],
},
stages: [
{
result: 'Passed',
status: 'Passed',
rerun_of_counter: null,
name: 'init',
counter: '1',
scheduled: true,
approval_type: 'success',
approved_by: 'changes',
operate_permission: true,
can_run: true,
jobs: [
{
name: 'test-all',
scheduled_date: 1642174416721,
state: 'Completed',
result: 'Failed',
},
{
name: 'lint',
scheduled_date: 1642177416721,
state: 'Completed',
result: 'Passed',
},
{
name: 'publish-deploy-replicas',
scheduled_date: 1642177416721,
state: 'Completed',
result: 'Passed',
},
{
name: 'package-publish',
scheduled_date: 1642177416721,
state: 'Completed',
result: 'Passed',
},
],
},
{
result: 'Passed',
status: 'Passed',
rerun_of_counter: null,
name: 'canary',
counter: '1',
scheduled: true,
approval_type: 'success',
approved_by: 'changes',
operate_permission: true,
can_run: true,
jobs: [
{
name: 'canary',
scheduled_date: 1642177485728,
state: 'Completed',
result: 'Passed',
},
],
},
{
result: 'Passed',
status: 'Passed',
rerun_of_counter: null,
name: 'deploy',
counter: '1',
scheduled: true,
approval_type: 'success',
approved_by: 'changes',
operate_permission: true,
can_run: true,
jobs: [
{
name: 'promote-and-deploy',
scheduled_date: 1642178467479,
state: 'Completed',
result: 'Passed',
},
],
},
],
},
],
},
};
describe('<GoCdBuildsInsights />', () => {
it('renders without exploding', () => {
const { getByText } = render(<GoCdBuildsInsights {...minProps} />);
expect(getByText('Run Frequency')).toBeInTheDocument();
expect(getByText('Mean Time to Recovery')).toBeInTheDocument();
expect(getByText('Mean Time Between Failures')).toBeInTheDocument();
expect(getByText('Failure Rate')).toBeInTheDocument();
});
it('renders mean time to recovery', () => {
const { getByText } = render(<GoCdBuildsInsights {...minProps} />);
expect(getByText('-1d -3h -21m -40s')).toBeInTheDocument();
});
it('renders mean time between failures', () => {
const { getByText } = render(<GoCdBuildsInsights {...minProps} />);
expect(getByText('2d 8h 23m 20s')).toBeInTheDocument();
});
it('renders failure rate', () => {
const { getByText } = render(<GoCdBuildsInsights {...minProps} />);
expect(getByText('11.11%')).toBeInTheDocument();
expect(getByText('(2 out of 18 jobs)')).toBeInTheDocument();
});
it('renders nothing when loading', () => {
const props = {
loading: true,
pipelineHistory: undefined,
error: undefined,
};
const { getByTestId } = render(<GoCdBuildsInsights {...props} />);
expect(() => getByTestId('GoCdBuildsInsightsBox')).toThrow();
});
});
@@ -0,0 +1,255 @@
/*
* Copyright 2021 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 React from 'react';
import { groupBy, mean } from 'lodash';
import {
PipelineHistory,
Job,
toBuildResultStatus,
GoCdBuildResultStatus,
} from '../../api/gocdApi.model';
import {
Box,
Grid,
Card,
CardContent,
Tooltip,
Typography,
} from '@material-ui/core';
import { DateTime, Duration } from 'luxon';
export type GoCdBuildsInsightsProps = {
pipelineHistory: PipelineHistory | undefined;
loading: boolean;
error: Error | undefined;
};
function runFrequency(pipelineHistory: PipelineHistory): string {
const lastMonth = DateTime.now().minus({ month: 1 });
const buildLastMonth = pipelineHistory.pipelines
.map(p => p.scheduled_date)
.filter((d): d is number => !!d)
.map(d => DateTime.fromMillis(d))
.filter(d => d > lastMonth).length;
return `${buildLastMonth} last month`;
}
function meanTimeBetweenFailures(jobs: Job[]): string {
const timeBetweenFailures: Duration[] = [];
for (let index = 1; index < jobs.length; index++) {
const job = jobs[index];
if (!job.result) {
continue;
}
if (toBuildResultStatus(job.result) === GoCdBuildResultStatus.error) {
let previousFailedJob: Job | null = null;
for (let j = index - 1; j >= 0; j--) {
const candidateJob = jobs[j];
if (!candidateJob.result) {
continue;
}
if (
toBuildResultStatus(candidateJob.result) ===
GoCdBuildResultStatus.error
) {
previousFailedJob = candidateJob;
break;
}
}
if (
!previousFailedJob ||
!job.scheduled_date ||
!previousFailedJob.scheduled_date
) {
continue;
}
const failedJobDate = DateTime.fromMillis(job.scheduled_date);
const previousFailedJobDate = DateTime.fromMillis(
previousFailedJob.scheduled_date,
);
const timeBetweenFailure = failedJobDate.diff(previousFailedJobDate);
timeBetweenFailures.push(timeBetweenFailure);
}
}
return formatMean(timeBetweenFailures);
}
/**
* Imagine a sequence like:
* S - S - S - F - S - F - F - S - S
*
* We iterate until finding a failure, and then iterate forward
* until we find the first immediate success to then
* calculate the time difference between the scheduling of the jobs.
*/
function meanTimeToRecovery(jobs: Job[]): string {
const timeToRecoverIntervals: Duration[] = [];
for (let index = 0; index < jobs.length; index++) {
const job = jobs[index];
if (!job.result) {
continue;
}
if (toBuildResultStatus(job.result) === GoCdBuildResultStatus.error) {
let nextSuccessfulJob: Job | null = null;
for (let j = index + 1; j < jobs.length; j++) {
const candidateJob = jobs[j];
if (!candidateJob.result) {
continue;
}
if (
toBuildResultStatus(candidateJob.result) ===
GoCdBuildResultStatus.successful
) {
nextSuccessfulJob = candidateJob;
break;
}
}
if (
!nextSuccessfulJob ||
!job.scheduled_date ||
!nextSuccessfulJob.scheduled_date
) {
continue;
}
const failedJobDate = DateTime.fromMillis(job.scheduled_date);
const successfulJobDate = DateTime.fromMillis(
nextSuccessfulJob.scheduled_date,
);
const timeToRecovery = successfulJobDate.diff(failedJobDate);
timeToRecoverIntervals.push(timeToRecovery);
}
}
return formatMean(timeToRecoverIntervals);
}
function formatMean(durations: Duration[]): string {
if (durations.length === 0) {
return 'N/A';
}
const mttr: Duration = Duration.fromMillis(
mean(durations.map(i => i.milliseconds)),
);
return mttr.toFormat("d'd' h'h' m'm' s's'");
}
function failureRate(jobs: Job[]): {
title: string;
subtitle: string;
} {
const resultGroups = new Map(
Object.entries(groupBy(jobs, 'result')).map(([key, value]) => [
toBuildResultStatus(key),
value.flat(),
]),
);
const failedJobs = resultGroups.get(GoCdBuildResultStatus.error);
if (!failedJobs) {
return {
title: '0',
subtitle: '(no failed jobs found)',
};
}
resultGroups.delete(GoCdBuildResultStatus.error);
const nonFailedJobs = Array.from(resultGroups.values()).flat();
const totalJobs = failedJobs.length + nonFailedJobs.length;
const percentage = (failedJobs.length / totalJobs) * 100;
const decimalPercentage = (Math.round(percentage * 100) / 100).toFixed(2);
return {
title: `${decimalPercentage}%`,
subtitle: `(${failedJobs.length} out of ${totalJobs} jobs)`,
};
}
export const GoCdBuildsInsights = (
props: GoCdBuildsInsightsProps,
): JSX.Element => {
const { pipelineHistory, loading, error } = props;
if (loading || error || !pipelineHistory) {
return <></>;
}
// We reverse the array to calculate insights to make sure jobs are ordered
// by their schedule date.
const stages = pipelineHistory.pipelines
.slice()
.reverse()
.map(p => p.stages)
.flat();
const jobs = stages.map(s => s.jobs).flat();
const failureRateObj: { title: string; subtitle: string } = failureRate(jobs);
return (
<Box data-testid="GoCdBuildsInsightsBox" sx={{ mb: 1 }}>
<Grid container spacing={1}>
<Grid item xs={6} sm={3}>
<Tooltip title="What is your deployment frequency?">
<Card>
<CardContent>
<Typography variant="body2">Run Frequency</Typography>
<Typography variant="h4">
{runFrequency(pipelineHistory)}
</Typography>
</CardContent>
</Card>
</Tooltip>
</Grid>
<Grid item xs={6} sm={3}>
<Tooltip title="How long does it take to fix a failure?">
<Card>
<CardContent>
<Typography variant="body2">Mean Time to Recovery</Typography>
<Typography variant="h4">{meanTimeToRecovery(jobs)}</Typography>
</CardContent>
</Card>
</Tooltip>
</Grid>
<Grid item xs={6} sm={3}>
<Tooltip title="How often do changes fail?">
<Card>
<CardContent>
<Typography variant="body2">
Mean Time Between Failures
</Typography>
<Typography variant="h4">
{meanTimeBetweenFailures(jobs)}
</Typography>
</CardContent>
</Card>
</Tooltip>
</Grid>
<Grid item xs={6} sm={3}>
<Tooltip title="What percentage of changes result in a failure?">
<Card>
<CardContent>
<Typography variant="body2">Failure Rate</Typography>
<Typography variant="h4">{failureRateObj.title}</Typography>
<Typography variant="body2">
{failureRateObj.subtitle}
</Typography>
</CardContent>
</Card>
</Tooltip>
</Grid>
</Grid>
</Box>
);
};
@@ -22,6 +22,7 @@ import GitHubIcon from '@material-ui/icons/GitHub';
import {
GoCdBuildResult,
GoCdBuildResultStatus,
toBuildResultStatus,
PipelineHistory,
Pipeline,
} from '../../api/gocdApi.model';
@@ -157,23 +158,6 @@ const renderError = (error: Error): JSX.Element => {
return <Alert severity="error">{error.message}</Alert>;
};
const toStageStatus = (status: string): GoCdBuildResultStatus => {
switch (status.toLocaleLowerCase('en-US')) {
case 'passed':
return GoCdBuildResultStatus.successful;
case 'failed':
return GoCdBuildResultStatus.error;
case 'aborted':
return GoCdBuildResultStatus.aborted;
case 'building':
return GoCdBuildResultStatus.running;
case 'pending':
return GoCdBuildResultStatus.pending;
default:
return GoCdBuildResultStatus.aborted;
}
};
const toBuildResults = (
entity: Entity,
builds: Pipeline[],
@@ -185,7 +169,7 @@ const toBuildResults = (
id: build.counter,
source: `${entitySourceLocation}/commit/${build.build_cause?.material_revisions[0]?.modifications[0].revision}`,
stages: build.stages.map(s => ({
status: toStageStatus(s.status),
status: toBuildResultStatus(s.status),
text: s.name,
})),
buildSlug: `${build.name}/${build.counter}`,