1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: DORA metrics lead time to production (#4589)

* Adds initial support for estimating dora metric lead time for changes
per feature toggle
This commit is contained in:
Fredrik Strand Oseberg 2023-08-30 14:39:43 +02:00 committed by GitHub
parent ff346adb94
commit 3b2d6a4cbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 455 additions and 20 deletions

View File

@ -41,6 +41,7 @@ import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi
import { ImportModal } from './Import/ImportModal'; import { ImportModal } from './Import/ImportModal';
import { IMPORT_BUTTON } from 'utils/testIds'; import { IMPORT_BUTTON } from 'utils/testIds';
import { EnterpriseBadge } from 'component/common/EnterpriseBadge/EnterpriseBadge'; import { EnterpriseBadge } from 'component/common/EnterpriseBadge/EnterpriseBadge';
import { ProjectDoraMetrics } from './ProjectDoraMetrics/ProjectDoraMetrics';
export const Project = () => { export const Project = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
@ -63,34 +64,53 @@ export const Project = () => {
title: 'Overview', title: 'Overview',
path: basePath, path: basePath,
name: 'overview', name: 'overview',
flag: undefined,
}, },
{ {
title: 'Health', title: 'Health',
path: `${basePath}/health`, path: `${basePath}/health`,
name: 'health', name: 'health',
flag: undefined,
}, },
{ {
title: 'Archive', title: 'Archive',
path: `${basePath}/archive`, path: `${basePath}/archive`,
name: 'archive', name: 'archive',
flag: undefined,
}, },
{ {
title: 'Change requests', title: 'Change requests',
path: `${basePath}/change-requests`, path: `${basePath}/change-requests`,
name: 'change-request', name: 'change-request',
isEnterprise: true, isEnterprise: true,
flag: undefined,
},
{
title: 'DORA Metrics',
path: `${basePath}/dora`,
name: 'dora',
flag: 'doraMetrics',
}, },
{ {
title: 'Event log', title: 'Event log',
path: `${basePath}/logs`, path: `${basePath}/logs`,
name: 'logs', name: 'logs',
flag: undefined,
}, },
{ {
title: 'Project settings', title: 'Project settings',
path: `${basePath}/settings`, path: `${basePath}/settings`,
name: 'settings', name: 'settings',
flag: undefined,
}, },
].filter(tab => !(isOss() && tab.isEnterprise)); ]
.filter(tab => {
if (tab.flag) {
return uiConfig.flags[tab.flag];
}
return true;
})
.filter(tab => !(isOss() && tab.isEnterprise));
const activeTab = [...tabs] const activeTab = [...tabs]
.reverse() .reverse()
@ -188,7 +208,8 @@ export const Project = () => {
variant="scrollable" variant="scrollable"
allowScrollButtonsMobile allowScrollButtonsMobile
> >
{tabs.map(tab => ( {tabs.map(tab => {
return (
<StyledTab <StyledTab
key={tab.title} key={tab.title}
label={tab.title} label={tab.title}
@ -205,7 +226,8 @@ export const Project = () => {
undefined undefined
} }
/> />
))} );
})}
</Tabs> </Tabs>
</StyledTabContainer> </StyledTabContainer>
</StyledHeader> </StyledHeader>
@ -242,6 +264,7 @@ export const Project = () => {
element={<ChangeRequestOverview />} element={<ChangeRequestOverview />}
/> />
<Route path="settings/*" element={<ProjectSettings />} /> <Route path="settings/*" element={<ProjectSettings />} />
<Route path="dora" element={<ProjectDoraMetrics />} />
<Route path="*" element={<ProjectOverview />} /> <Route path="*" element={<ProjectOverview />} />
</Routes> </Routes>
<ImportModal <ImportModal

View File

@ -0,0 +1,194 @@
import { Box, List, ListItem } from '@mui/material';
import { useProjectDoraMetrics } from 'hooks/api/getters/useProjectDoraMetrics/useProjectDoraMetrics';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useMemo } from 'react';
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
import {
Table,
SortableTableHeader,
TableBody,
TableCell,
TableRow,
TablePlaceholder,
} from 'component/common/Table';
import { PageContent } from 'component/common/PageContent/PageContent';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Badge } from 'component/common/Badge/Badge';
const resolveDoraMetrics = (input: number) => {
const ONE_MONTH = 30;
const ONE_WEEK = 7;
if (input >= ONE_MONTH) {
return <Badge color="error">Low</Badge>;
}
if (input <= ONE_MONTH && input >= ONE_WEEK + 1) {
return <Badge>Medium</Badge>;
}
if (input <= ONE_WEEK) {
return <Badge color="success">High</Badge>;
}
};
export const ProjectDoraMetrics = () => {
const projectId = useRequiredPathParam('projectId');
const { dora, loading } = useProjectDoraMetrics(projectId);
const data = useMemo(() => {
if (loading) {
return Array(5).fill({
name: 'Featurename',
timeToProduction: 'Tag type for production',
});
}
return dora.features;
}, [dora, loading]);
const columns = useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
width: '50%',
Cell: ({
row: {
original: { name, description },
},
}: any) => {
return (
<Box
data-loading
sx={{
pl: 2,
pr: 1,
paddingTop: 2,
paddingBottom: 2,
display: 'flex',
alignItems: 'center',
}}
>
{name}
</Box>
);
},
sortType: 'alphanumeric',
},
{
Header: 'Time to production',
id: 'Time to production',
align: 'center',
Cell: ({ row: { original } }: any) => (
<Box
sx={{ display: 'flex', justifyContent: 'center' }}
data-loading
>
{original.timeToProduction} days
</Box>
),
width: 150,
disableGlobalFilter: true,
disableSortBy: true,
},
{
Header: 'DORA',
id: 'dora',
align: 'center',
Cell: ({ row: { original } }: any) => (
<Box
sx={{ display: 'flex', justifyContent: 'center' }}
data-loading
>
{resolveDoraMetrics(original.timeToProduction)}
</Box>
),
width: 200,
disableGlobalFilter: true,
disableSortBy: true,
},
],
[JSON.stringify(dora.features), loading]
);
const initialState = useMemo(
() => ({
sortBy: [{ id: 'name', desc: false }],
hiddenColumns: ['description'],
}),
[]
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state: { globalFilter },
setGlobalFilter,
} = useTable(
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
},
useGlobalFilter,
useSortBy
);
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Lead time for changes (per feature toggle)`}
/>
}
>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No tags found matching &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No tags available. Get started by adding one.
</TablePlaceholder>
}
/>
}
/>
</PageContent>
);
};

View File

@ -0,0 +1,38 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
export const useProjectDoraMetrics = (
projectId: string,
options: SWRConfiguration = {}
) => {
const KEY = `api/admin/projects/${projectId}/dora`;
const path = formatApiPath(KEY);
const fetcher = () => {
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('Dora metrics'))
.then(res => res.json());
};
const { data, error } = useSWR(KEY, fetcher, options);
const [loading, setLoading] = useState(!error && !data);
const refetchDoraMetrics = () => {
mutate(KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
dora: data || {},
error,
loading,
refetchDoraMetrics,
};
};

View File

@ -58,6 +58,8 @@ export interface IFlags {
newApplicationList?: boolean; newApplicationList?: boolean;
integrationsRework?: boolean; integrationsRework?: boolean;
multipleRoles?: boolean; multipleRoles?: boolean;
doraMetrics?: boolean;
[key: string]: boolean | Variant | undefined;
} }
export interface IVersionInfo { export interface IVersionInfo {

View File

@ -75,6 +75,7 @@ exports[`should create default config 1`] = `
"demo": false, "demo": false,
"disableBulkToggle": false, "disableBulkToggle": false,
"disableNotifications": false, "disableNotifications": false,
"doraMetrics": false,
"embedProxy": true, "embedProxy": true,
"embedProxyFrontend": true, "embedProxyFrontend": true,
"featuresExportImport": true, "featuresExportImport": true,
@ -112,6 +113,7 @@ exports[`should create default config 1`] = `
"demo": false, "demo": false,
"disableBulkToggle": false, "disableBulkToggle": false,
"disableNotifications": false, "disableNotifications": false,
"doraMetrics": false,
"embedProxy": true, "embedProxy": true,
"embedProxyFrontend": true, "embedProxyFrontend": true,
"featuresExportImport": true, "featuresExportImport": true,

View File

@ -9,6 +9,7 @@ import {
IProjectStatsStore, IProjectStatsStore,
} from 'lib/types/stores/project-stats-store-type'; } from 'lib/types/stores/project-stats-store-type';
import { Db } from './db'; import { Db } from './db';
import { DoraFeaturesSchema } from 'lib/openapi';
const TABLE = 'project_stats'; const TABLE = 'project_stats';
@ -144,6 +145,51 @@ class ProjectStatsStore implements IProjectStatsStore {
.orderBy('events.created_at', 'asc'); .orderBy('events.created_at', 'asc');
return result; return result;
} }
async getTimeToProdDatesForFeatureToggles(
projectId: string,
featureToggleNames: string[],
): Promise<DoraFeaturesSchema[]> {
const result = await this.db
.select('events.feature_name')
.distinctOn('events.feature_name')
.select(
this.db.raw(
'events.created_at as enabled, features.created_at as created',
),
)
.from('events')
.innerJoin(
'environments',
'environments.name',
'=',
'events.environment',
)
.innerJoin('features', 'features.name', '=', 'events.feature_name')
.whereIn('events.feature_name', featureToggleNames)
.where('events.type', '=', 'feature-environment-enabled')
.where('environments.type', '=', 'production')
.where('features.type', '=', 'release')
.where(this.db.raw('events.created_at > features.created_at'))
.where('features.project', '=', projectId)
.orderBy('events.feature_name')
.orderBy('events.created_at', 'asc');
const timeDifferenceData: DoraFeaturesSchema[] = result.map((row) => {
const enabledDate = new Date(row.enabled).getTime();
const createdDate = new Date(row.created).getTime();
const timeDifferenceInDays = Math.floor(
(enabledDate - createdDate) / (1000 * 60 * 60 * 24),
);
return {
name: row.feature_name,
timeToProduction: timeDifferenceInDays,
};
});
return timeDifferenceData;
}
} }
export default ProjectStatsStore; export default ProjectStatsStore;

View File

@ -155,6 +155,8 @@ import {
createStrategyVariantSchema, createStrategyVariantSchema,
clientSegmentSchema, clientSegmentSchema,
createGroupSchema, createGroupSchema,
doraFeaturesSchema,
projectDoraMetricsSchema,
} from './spec'; } from './spec';
import { IServerOption } from '../types'; import { IServerOption } from '../types';
import { mapValues, omitKeys } from '../util'; import { mapValues, omitKeys } from '../util';
@ -367,6 +369,8 @@ export const schemas: UnleashSchemas = {
createStrategyVariantSchema, createStrategyVariantSchema,
clientSegmentSchema, clientSegmentSchema,
createGroupSchema, createGroupSchema,
doraFeaturesSchema,
projectDoraMetricsSchema,
}; };
// Remove JSONSchema keys that would result in an invalid OpenAPI spec. // Remove JSONSchema keys that would result in an invalid OpenAPI spec.

View File

@ -0,0 +1,24 @@
import { FromSchema } from 'json-schema-to-ts';
export const doraFeaturesSchema = {
$id: '#/components/schemas/doraFeaturesSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'timeToProduction'],
description:
'The representation of a dora time to production feature metric',
properties: {
name: {
type: 'string',
description: 'The name of a feature toggle',
},
timeToProduction: {
type: 'number',
description:
'The average number of days it takes a feature toggle to get into production',
},
},
components: {},
} as const;
export type DoraFeaturesSchema = FromSchema<typeof doraFeaturesSchema>;

View File

@ -154,3 +154,5 @@ export * from './client-segment-schema';
export * from './update-feature-type-lifetime-schema'; export * from './update-feature-type-lifetime-schema';
export * from './create-group-schema'; export * from './create-group-schema';
export * from './application-usage-schema'; export * from './application-usage-schema';
export * from './dora-features-schema';
export * from './project-dora-metrics-schema';

View File

@ -0,0 +1,27 @@
import { FromSchema } from 'json-schema-to-ts';
import { doraFeaturesSchema } from './dora-features-schema';
export const projectDoraMetricsSchema = {
$id: '#/components/schemas/projectDoraMetricsSchema',
type: 'object',
additionalProperties: false,
required: ['features'],
description: 'A projects dora metrics',
properties: {
features: {
type: 'array',
items: { $ref: '#/components/schemas/doraFeaturesSchema' },
description:
'An array of objects containing feature toggle name and timeToProduction values',
},
},
components: {
schemas: {
doraFeaturesSchema,
},
},
} as const;
export type ProjectDoraMetricsSchema = FromSchema<
typeof projectDoraMetricsSchema
>;

View File

@ -15,6 +15,8 @@ import ProjectService from '../../../services/project-service';
import VariantsController from './variants'; import VariantsController from './variants';
import { import {
createResponseSchema, createResponseSchema,
ProjectDoraMetricsSchema,
projectDoraMetricsSchema,
ProjectOverviewSchema, ProjectOverviewSchema,
projectOverviewSchema, projectOverviewSchema,
projectsSchema, projectsSchema,
@ -27,6 +29,7 @@ import { ProjectApiTokenController } from './api-token';
import ProjectArchiveController from './project-archive'; import ProjectArchiveController from './project-archive';
import { createKnexTransactionStarter } from '../../../db/transaction'; import { createKnexTransactionStarter } from '../../../db/transaction';
import { Db } from '../../../db/db'; import { Db } from '../../../db/db';
import { InvalidOperationError } from '../../../error';
export default class ProjectApi extends Controller { export default class ProjectApi extends Controller {
private projectService: ProjectService; private projectService: ProjectService;
@ -81,6 +84,26 @@ export default class ProjectApi extends Controller {
], ],
}); });
this.route({
method: 'get',
path: '/:projectId/dora',
handler: this.getProjectDora,
permission: NONE,
middleware: [
services.openApiService.validPath({
tags: ['Projects'],
operationId: 'getProjectDora',
summary: 'Get an overview project dora metrics.',
description:
'This endpoint returns an overview of the specified dora metrics',
responses: {
200: createResponseSchema('projectDoraMetricsSchema'),
...getStandardResponses(401, 403, 404),
},
}),
],
});
this.use( this.use(
'/', '/',
new ProjectFeaturesController( new ProjectFeaturesController(
@ -136,4 +159,26 @@ export default class ProjectApi extends Controller {
serializeDates(overview), serializeDates(overview),
); );
} }
async getProjectDora(
req: IAuthRequest,
res: Response<ProjectDoraMetricsSchema>,
): Promise<void> {
if (this.config.flagResolver.isEnabled('doraMetrics')) {
const { projectId } = req.params;
const dora = await this.projectService.getDoraMetrics(projectId);
this.openApiService.respondWithValidation(
200,
res,
projectDoraMetricsSchema.$id,
dora,
);
} else {
throw new InvalidOperationError(
'Feature dora metrics is not enabled',
);
}
}
} }

View File

@ -56,6 +56,7 @@ import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-p
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type'; import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
import { uniqueByKey } from '../util/unique'; import { uniqueByKey } from '../util/unique';
import { PermissionError } from '../error'; import { PermissionError } from '../error';
import { ProjectDoraMetricsSchema } from 'lib/openapi';
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown'; const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
@ -684,6 +685,20 @@ export default class ProjectService {
} }
} }
async getDoraMetrics(projectId: string): Promise<ProjectDoraMetricsSchema> {
const featureToggleNames = (await this.featureToggleStore.getAll()).map(
(feature) => feature.name,
);
const avgTimeToProductionPerToggle =
await this.projectStatsStore.getTimeToProdDatesForFeatureToggles(
projectId,
featureToggleNames,
);
return { features: avgTimeToProductionPerToggle };
}
async changeRole( async changeRole(
projectId: string, projectId: string,
roleId: number, roleId: number,
@ -959,6 +974,7 @@ export default class ProjectService {
: Promise.resolve(false), : Promise.resolve(false),
this.projectStatsStore.getProjectStats(projectId), this.projectStatsStore.getProjectStats(projectId),
]); ]);
return { return {
stats: projectStats, stats: projectStats,
name: project.name, name: project.name,

View File

@ -29,7 +29,8 @@ export type IFlagKey =
| 'customRootRolesKillSwitch' | 'customRootRolesKillSwitch'
| 'newApplicationList' | 'newApplicationList'
| 'integrationsRework' | 'integrationsRework'
| 'multipleRoles'; | 'multipleRoles'
| 'doraMetrics';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -137,6 +138,7 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST, process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST,
false, false,
), ),
doraMetrics: parseEnvVarBoolean(process.env.UNLEASH_DORA_METRICS, false),
}; };
export const defaultExperimentalOptions: IExperimentalOptions = { export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -1,3 +1,4 @@
import { DoraFeaturesSchema } from 'lib/openapi';
import { IProjectStats } from 'lib/services/project-service'; import { IProjectStats } from 'lib/services/project-service';
export interface ICreateEnabledDates { export interface ICreateEnabledDates {
@ -9,4 +10,8 @@ export interface IProjectStatsStore {
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>; updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
getProjectStats(projectId: string): Promise<IProjectStats>; getProjectStats(projectId: string): Promise<IProjectStats>;
getTimeToProdDates(projectId: string): Promise<ICreateEnabledDates[]>; getTimeToProdDates(projectId: string): Promise<ICreateEnabledDates[]>;
getTimeToProdDatesForFeatureToggles(
projectId: string,
toggleNames: string[],
): Promise<DoraFeaturesSchema[]>;
} }

View File

@ -42,6 +42,7 @@ process.nextTick(async () => {
lastSeenByEnvironment: true, lastSeenByEnvironment: true,
segmentChangeRequests: true, segmentChangeRequests: true,
newApplicationList: true, newApplicationList: true,
doraMetrics: true,
}, },
}, },
authentication: { authentication: {

View File

@ -20,4 +20,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore {
getTimeToProdDates(): Promise<ICreateEnabledDates[]> { getTimeToProdDates(): Promise<ICreateEnabledDates[]> {
throw new Error('not implemented'); throw new Error('not implemented');
} }
getTimeToProdDatesForFeatureToggles(): Promise<any> {
throw new Error('not implemented');
}
} }