mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
fix: refactor calculate time to prod (#3333)
This PR changes how we calculate average time to production. Instead of calculating fleeting 30 day windows and calculating the past and current window, we now calculate a flat average across the entire project life. This is less error prone as each feature will be tied to the earliest time it was turned on in a production environment.
This commit is contained in:
parent
2537071a3d
commit
e2ad0cae45
@ -35,6 +35,12 @@ const StyledWidget = styled(Box)(({ theme }) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const StyledTimeToProductionDescription = styled(Typography)(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: theme.typography.body2.fontSize,
|
||||||
|
lineHeight: theme.typography.body2.lineHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
interface IProjectStatsProps {
|
interface IProjectStatsProps {
|
||||||
stats: ProjectStatsSchema;
|
stats: ProjectStatsSchema;
|
||||||
}
|
}
|
||||||
@ -95,16 +101,18 @@ export const ProjectStats = ({ stats }: IProjectStatsProps) => {
|
|||||||
<Typography component="span">days</Typography>
|
<Typography component="span">days</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
change={calculatePercentage(
|
customChangeElement={
|
||||||
avgTimeToProdCurrentWindow,
|
<StyledTimeToProductionDescription>
|
||||||
avgTimeToProdPastWindow
|
In project life
|
||||||
)}
|
</StyledTimeToProductionDescription>
|
||||||
|
}
|
||||||
percentage
|
percentage
|
||||||
>
|
>
|
||||||
<HelpPopper id="avg-time-to-prod">
|
<HelpPopper id="avg-time-to-prod">
|
||||||
How long did it take on average from a feature toggle
|
How long did it take on average from a feature toggle
|
||||||
was created until it was enabled in an environment of
|
was created until it was enabled in an environment of
|
||||||
type production.
|
type production. This is calculated only from feature
|
||||||
|
toggles with the type of "release".
|
||||||
</HelpPopper>
|
</HelpPopper>
|
||||||
</StatusBox>
|
</StatusBox>
|
||||||
</StyledWidget>
|
</StyledWidget>
|
||||||
|
@ -33,8 +33,9 @@ const StyledTypographyChange = styled(Typography)(({ theme }) => ({
|
|||||||
interface IStatusBoxProps {
|
interface IStatusBoxProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
boxText: ReactNode;
|
boxText: ReactNode;
|
||||||
change: number;
|
change?: number;
|
||||||
percentage?: boolean;
|
percentage?: boolean;
|
||||||
|
customChangeElement?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveIcon = (change: number) => {
|
const resolveIcon = (change: number) => {
|
||||||
@ -59,6 +60,7 @@ export const StatusBox: FC<IStatusBoxProps> = ({
|
|||||||
change,
|
change,
|
||||||
percentage,
|
percentage,
|
||||||
children,
|
children,
|
||||||
|
customChangeElement,
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
@ -75,34 +77,44 @@ export const StatusBox: FC<IStatusBoxProps> = ({
|
|||||||
>
|
>
|
||||||
<StyledTypographyCount>{boxText}</StyledTypographyCount>
|
<StyledTypographyCount>{boxText}</StyledTypographyCount>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={change !== 0}
|
condition={Boolean(customChangeElement)}
|
||||||
show={
|
show={
|
||||||
<StyledBoxChangeContainer>
|
<StyledBoxChangeContainer>
|
||||||
<Box
|
{customChangeElement}
|
||||||
sx={{
|
|
||||||
...flexRow,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{resolveIcon(change)}
|
|
||||||
<StyledTypographyChange
|
|
||||||
color={resolveColor(change)}
|
|
||||||
>
|
|
||||||
{change > 0 ? '+' : ''}
|
|
||||||
{change}
|
|
||||||
{percentage ? '%' : ''}
|
|
||||||
</StyledTypographyChange>
|
|
||||||
</Box>
|
|
||||||
<StyledTypographySubtext>
|
|
||||||
this month
|
|
||||||
</StyledTypographySubtext>
|
|
||||||
</StyledBoxChangeContainer>
|
</StyledBoxChangeContainer>
|
||||||
}
|
}
|
||||||
elseShow={
|
elseShow={
|
||||||
<StyledBoxChangeContainer>
|
<ConditionallyRender
|
||||||
<StyledTypographySubtext>
|
condition={change !== undefined && change !== 0}
|
||||||
No change
|
show={
|
||||||
</StyledTypographySubtext>
|
<StyledBoxChangeContainer>
|
||||||
</StyledBoxChangeContainer>
|
<Box
|
||||||
|
sx={{
|
||||||
|
...flexRow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resolveIcon(change as number)}
|
||||||
|
<StyledTypographyChange
|
||||||
|
color={resolveColor(change as number)}
|
||||||
|
>
|
||||||
|
{(change as number) > 0 ? '+' : ''}
|
||||||
|
{change}
|
||||||
|
{percentage ? '%' : ''}
|
||||||
|
</StyledTypographyChange>
|
||||||
|
</Box>
|
||||||
|
<StyledTypographySubtext>
|
||||||
|
this month
|
||||||
|
</StyledTypographySubtext>
|
||||||
|
</StyledBoxChangeContainer>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<StyledBoxChangeContainer>
|
||||||
|
<StyledTypographySubtext>
|
||||||
|
No change
|
||||||
|
</StyledTypographySubtext>
|
||||||
|
</StyledBoxChangeContainer>
|
||||||
|
}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -11,7 +11,6 @@ const TABLE = 'project_stats';
|
|||||||
|
|
||||||
const PROJECT_STATS_COLUMNS = [
|
const PROJECT_STATS_COLUMNS = [
|
||||||
'avg_time_to_prod_current_window',
|
'avg_time_to_prod_current_window',
|
||||||
'avg_time_to_prod_past_window',
|
|
||||||
'project',
|
'project',
|
||||||
'features_created_current_window',
|
'features_created_current_window',
|
||||||
'features_created_past_window',
|
'features_created_past_window',
|
||||||
@ -24,7 +23,6 @@ const PROJECT_STATS_COLUMNS = [
|
|||||||
|
|
||||||
interface IProjectStatsRow {
|
interface IProjectStatsRow {
|
||||||
avg_time_to_prod_current_window: number;
|
avg_time_to_prod_current_window: number;
|
||||||
avg_time_to_prod_past_window: number;
|
|
||||||
features_created_current_window: number;
|
features_created_current_window: number;
|
||||||
features_created_past_window: number;
|
features_created_past_window: number;
|
||||||
features_archived_current_window: number;
|
features_archived_current_window: number;
|
||||||
@ -59,7 +57,6 @@ class ProjectStatsStore implements IProjectStatsStore {
|
|||||||
.insert({
|
.insert({
|
||||||
avg_time_to_prod_current_window:
|
avg_time_to_prod_current_window:
|
||||||
status.avgTimeToProdCurrentWindow,
|
status.avgTimeToProdCurrentWindow,
|
||||||
avg_time_to_prod_past_window: status.avgTimeToProdPastWindow,
|
|
||||||
project: projectId,
|
project: projectId,
|
||||||
features_created_current_window: status.createdCurrentWindow,
|
features_created_current_window: status.createdCurrentWindow,
|
||||||
features_created_past_window: status.createdPastWindow,
|
features_created_past_window: status.createdPastWindow,
|
||||||
@ -88,7 +85,6 @@ class ProjectStatsStore implements IProjectStatsStore {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
return {
|
return {
|
||||||
avgTimeToProdCurrentWindow: 0,
|
avgTimeToProdCurrentWindow: 0,
|
||||||
avgTimeToProdPastWindow: 0,
|
|
||||||
createdCurrentWindow: 0,
|
createdCurrentWindow: 0,
|
||||||
createdPastWindow: 0,
|
createdPastWindow: 0,
|
||||||
archivedCurrentWindow: 0,
|
archivedCurrentWindow: 0,
|
||||||
@ -101,7 +97,6 @@ class ProjectStatsStore implements IProjectStatsStore {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
avgTimeToProdCurrentWindow: row.avg_time_to_prod_current_window,
|
avgTimeToProdCurrentWindow: row.avg_time_to_prod_current_window,
|
||||||
avgTimeToProdPastWindow: row.avg_time_to_prod_past_window,
|
|
||||||
createdCurrentWindow: row.features_created_current_window,
|
createdCurrentWindow: row.features_created_current_window,
|
||||||
createdPastWindow: row.features_created_past_window,
|
createdPastWindow: row.features_created_past_window,
|
||||||
archivedCurrentWindow: row.features_archived_current_window,
|
archivedCurrentWindow: row.features_archived_current_window,
|
||||||
|
@ -6,7 +6,6 @@ export const projectStatsSchema = {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: [
|
required: [
|
||||||
'avgTimeToProdCurrentWindow',
|
'avgTimeToProdCurrentWindow',
|
||||||
'avgTimeToProdPastWindow',
|
|
||||||
'createdCurrentWindow',
|
'createdCurrentWindow',
|
||||||
'createdPastWindow',
|
'createdPastWindow',
|
||||||
'archivedCurrentWindow',
|
'archivedCurrentWindow',
|
||||||
@ -27,12 +26,6 @@ Stats are divided into current and previous **windows**.
|
|||||||
description:
|
description:
|
||||||
'The average time from when a feature was created to when it was enabled in the "production" environment during the current window',
|
'The average time from when a feature was created to when it was enabled in the "production" environment during the current window',
|
||||||
},
|
},
|
||||||
avgTimeToProdPastWindow: {
|
|
||||||
type: 'number',
|
|
||||||
example: 10,
|
|
||||||
description:
|
|
||||||
'The average time from when a feature was created to when it was enabled in the "production" environment during the previous window',
|
|
||||||
},
|
|
||||||
createdCurrentWindow: {
|
createdCurrentWindow: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
example: 15,
|
example: 15,
|
||||||
|
@ -65,7 +65,6 @@ type Count = number;
|
|||||||
|
|
||||||
export interface IProjectStats {
|
export interface IProjectStats {
|
||||||
avgTimeToProdCurrentWindow: Days;
|
avgTimeToProdCurrentWindow: Days;
|
||||||
avgTimeToProdPastWindow: Days;
|
|
||||||
createdCurrentWindow: Count;
|
createdCurrentWindow: Count;
|
||||||
createdPastWindow: Count;
|
createdPastWindow: Count;
|
||||||
archivedCurrentWindow: Count;
|
archivedCurrentWindow: Count;
|
||||||
@ -679,6 +678,12 @@ export default class ProjectService {
|
|||||||
project: projectId,
|
project: projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const archivedFeatures = await this.featureToggleStore.getAll({
|
||||||
|
archived: true,
|
||||||
|
type: 'release',
|
||||||
|
project: projectId,
|
||||||
|
});
|
||||||
|
|
||||||
const dateMinusThirtyDays = subDays(new Date(), 30).toISOString();
|
const dateMinusThirtyDays = subDays(new Date(), 30).toISOString();
|
||||||
const dateMinusSixtyDays = subDays(new Date(), 60).toISOString();
|
const dateMinusSixtyDays = subDays(new Date(), 60).toISOString();
|
||||||
|
|
||||||
@ -743,54 +748,24 @@ export default class ProjectService {
|
|||||||
// Get all events for features that correspond to feature toggle environment ON
|
// Get all events for features that correspond to feature toggle environment ON
|
||||||
// Filter out events that are not a production evironment
|
// Filter out events that are not a production evironment
|
||||||
|
|
||||||
const eventsCurrentWindow = await this.eventStore.query([
|
const allFeatures = [...features, ...archivedFeatures];
|
||||||
{
|
|
||||||
op: 'forFeatures',
|
|
||||||
parameters: {
|
|
||||||
features: features.map((feature) => feature.name),
|
|
||||||
environments: productionEnvironments.map((env) => env.name),
|
|
||||||
type: FEATURE_ENVIRONMENT_ENABLED,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: 'beforeDate',
|
|
||||||
parameters: {
|
|
||||||
dateAccessor: 'created_at',
|
|
||||||
date: dateMinusThirtyDays,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const eventsPastWindow = await this.eventStore.query([
|
const eventsData = await this.eventStore.query([
|
||||||
{
|
{
|
||||||
op: 'forFeatures',
|
op: 'forFeatures',
|
||||||
parameters: {
|
parameters: {
|
||||||
features: features.map((feature) => feature.name),
|
features: allFeatures.map((feature) => feature.name),
|
||||||
environments: productionEnvironments.map((env) => env.name),
|
environments: productionEnvironments.map((env) => env.name),
|
||||||
type: FEATURE_ENVIRONMENT_ENABLED,
|
type: FEATURE_ENVIRONMENT_ENABLED,
|
||||||
projectId,
|
projectId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
op: 'betweenDate',
|
|
||||||
parameters: {
|
|
||||||
dateAccessor: 'created_at',
|
|
||||||
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const currentWindowTimeToProdReadModel = new TimeToProduction(
|
const currentWindowTimeToProdReadModel = new TimeToProduction(
|
||||||
features,
|
allFeatures,
|
||||||
productionEnvironments,
|
productionEnvironments,
|
||||||
eventsCurrentWindow,
|
eventsData,
|
||||||
);
|
|
||||||
|
|
||||||
const pastWindowTimeToProdReadModel = new TimeToProduction(
|
|
||||||
features,
|
|
||||||
productionEnvironments,
|
|
||||||
eventsPastWindow,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectMembersAddedCurrentWindow =
|
const projectMembersAddedCurrentWindow =
|
||||||
@ -804,8 +779,6 @@ export default class ProjectService {
|
|||||||
updates: {
|
updates: {
|
||||||
avgTimeToProdCurrentWindow:
|
avgTimeToProdCurrentWindow:
|
||||||
currentWindowTimeToProdReadModel.calculateAverageTimeToProd(),
|
currentWindowTimeToProdReadModel.calculateAverageTimeToProd(),
|
||||||
avgTimeToProdPastWindow:
|
|
||||||
pastWindowTimeToProdReadModel.calculateAverageTimeToProd(),
|
|
||||||
createdCurrentWindow: createdCurrentWindow.length,
|
createdCurrentWindow: createdCurrentWindow.length,
|
||||||
createdPastWindow: createdPastWindow.length,
|
createdPastWindow: createdPastWindow.length,
|
||||||
archivedCurrentWindow: archivedCurrentWindow.length,
|
archivedCurrentWindow: archivedCurrentWindow.length,
|
||||||
|
19
src/migrations/20230316092547-remove-project-stats-column.js
Normal file
19
src/migrations/20230316092547-remove-project-stats-column.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
exports.up = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER table project_stats
|
||||||
|
DROP COLUMN avg_time_to_prod_past_window
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER table project_stats
|
||||||
|
ADD COLUMN IF NOT EXISTS avg_time_to_prod_past_window INTEGER DEFAULT 0
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
@ -2937,11 +2937,6 @@ Stats are divided into current and previous **windows**.
|
|||||||
"example": 10,
|
"example": 10,
|
||||||
"type": "number",
|
"type": "number",
|
||||||
},
|
},
|
||||||
"avgTimeToProdPastWindow": {
|
|
||||||
"description": "The average time from when a feature was created to when it was enabled in the "production" environment during the previous window",
|
|
||||||
"example": 10,
|
|
||||||
"type": "number",
|
|
||||||
},
|
|
||||||
"createdCurrentWindow": {
|
"createdCurrentWindow": {
|
||||||
"description": "The number of feature toggles created during the current window",
|
"description": "The number of feature toggles created during the current window",
|
||||||
"example": 15,
|
"example": 15,
|
||||||
@ -2970,7 +2965,6 @@ Stats are divided into current and previous **windows**.
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"avgTimeToProdCurrentWindow",
|
"avgTimeToProdCurrentWindow",
|
||||||
"avgTimeToProdPastWindow",
|
|
||||||
"createdCurrentWindow",
|
"createdCurrentWindow",
|
||||||
"createdPastWindow",
|
"createdPastWindow",
|
||||||
"archivedCurrentWindow",
|
"archivedCurrentWindow",
|
||||||
|
@ -1231,8 +1231,7 @@ test('should calculate average time to production', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await projectService.getStatusUpdates(project.id);
|
const result = await projectService.getStatusUpdates(project.id);
|
||||||
expect(result.updates.avgTimeToProdCurrentWindow).toBe(14);
|
expect(result.updates.avgTimeToProdCurrentWindow).toBe(11.4);
|
||||||
expect(result.updates.avgTimeToProdPastWindow).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should get correct amount of features created in current and past window', async () => {
|
test('should get correct amount of features created in current and past window', async () => {
|
||||||
|
Loading…
Reference in New Issue
Block a user