mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-01 13:47:27 +02:00
Project stats 4.22.4 (#3504)
This commit is contained in:
parent
ed47e5663e
commit
5208038fa4
@ -89,6 +89,7 @@ exports[`should create default config 1`] = `
|
|||||||
"projectScopedSegments": false,
|
"projectScopedSegments": false,
|
||||||
"projectScopedStickiness": false,
|
"projectScopedStickiness": false,
|
||||||
"projectStatusApi": false,
|
"projectStatusApi": false,
|
||||||
|
"projectStatusApiImprovements": false,
|
||||||
"responseTimeWithAppNameKillSwitch": false,
|
"responseTimeWithAppNameKillSwitch": false,
|
||||||
"strictSchemaValidation": false,
|
"strictSchemaValidation": false,
|
||||||
},
|
},
|
||||||
@ -117,6 +118,7 @@ exports[`should create default config 1`] = `
|
|||||||
"projectScopedSegments": false,
|
"projectScopedSegments": false,
|
||||||
"projectScopedStickiness": false,
|
"projectScopedStickiness": false,
|
||||||
"projectStatusApi": false,
|
"projectStatusApi": false,
|
||||||
|
"projectStatusApiImprovements": false,
|
||||||
"responseTimeWithAppNameKillSwitch": false,
|
"responseTimeWithAppNameKillSwitch": false,
|
||||||
"strictSchemaValidation": false,
|
"strictSchemaValidation": false,
|
||||||
},
|
},
|
||||||
|
@ -110,16 +110,16 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return rows.map(this.rowToFeature);
|
return rows.map(this.rowToFeature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByDate(queryModifiers: {
|
async countByDate(queryModifiers: {
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
project?: string;
|
project?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
range?: string[];
|
range?: string[];
|
||||||
dateAccessor: string;
|
dateAccessor: string;
|
||||||
}): Promise<FeatureToggle[]> {
|
}): Promise<number> {
|
||||||
const { project, archived, dateAccessor } = queryModifiers;
|
const { project, archived, dateAccessor } = queryModifiers;
|
||||||
let query = this.db
|
let query = this.db
|
||||||
.select(FEATURE_COLUMNS)
|
.count()
|
||||||
.from(TABLE)
|
.from(TABLE)
|
||||||
.where({ project })
|
.where({ project })
|
||||||
.modify(FeatureToggleStore.filterByArchived, archived);
|
.modify(FeatureToggleStore.filterByArchived, archived);
|
||||||
@ -135,8 +135,8 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await query;
|
const queryResult = await query.first();
|
||||||
return rows.map(this.rowToFeature);
|
return parseInt(queryResult.count || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -4,7 +4,10 @@ import metricsHelper from '../util/metrics-helper';
|
|||||||
import { DB_TIME } from '../metric-events';
|
import { DB_TIME } from '../metric-events';
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import { IProjectStats } from 'lib/services/project-service';
|
import { IProjectStats } from 'lib/services/project-service';
|
||||||
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
import {
|
||||||
|
ICreateEnabledDates,
|
||||||
|
IProjectStatsStore,
|
||||||
|
} from 'lib/types/stores/project-stats-store-type';
|
||||||
import { Db } from './db';
|
import { Db } from './db';
|
||||||
|
|
||||||
const TABLE = 'project_stats';
|
const TABLE = 'project_stats';
|
||||||
@ -107,6 +110,40 @@ class ProjectStatsStore implements IProjectStatsStore {
|
|||||||
row.project_members_added_current_window,
|
row.project_members_added_current_window,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// we're not calculating time difference in a DB as it requires specialized
|
||||||
|
// time aware libraries
|
||||||
|
async getTimeToProdDates(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<ICreateEnabledDates[]> {
|
||||||
|
const result = await this.db
|
||||||
|
.select('events.feature_name')
|
||||||
|
// select only first enabled event, distinct works with orderBy
|
||||||
|
.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')
|
||||||
|
.where('events.type', '=', 'feature-environment-enabled')
|
||||||
|
.where('environments.type', '=', 'production')
|
||||||
|
.where('features.type', '=', 'release')
|
||||||
|
// exclude events for features that were previously deleted
|
||||||
|
.where(this.db.raw('events.created_at > features.created_at'))
|
||||||
|
.where('features.project', '=', projectId)
|
||||||
|
.orderBy('events.feature_name')
|
||||||
|
// first enabled event
|
||||||
|
.orderBy('events.created_at', 'asc');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProjectStatsStore;
|
export default ProjectStatsStore;
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
import { calculateAverageTimeToProd } from './time-to-production';
|
||||||
|
|
||||||
|
describe('calculate average time to production', () => {
|
||||||
|
test('should calculate average correctly', () => {
|
||||||
|
const timeToProduction = calculateAverageTimeToProd([
|
||||||
|
{
|
||||||
|
created: new Date('2022-12-05T09:37:32.483Z'),
|
||||||
|
enabled: new Date('2023-01-25T09:37:32.504Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
created: new Date('2023-01-19T09:37:32.484Z'),
|
||||||
|
enabled: new Date('2023-01-31T09:37:32.506Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
created: new Date('2023-01-19T09:37:32.484Z'),
|
||||||
|
enabled: new Date('2023-02-02T09:37:32.509Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
created: new Date('2023-01-19T09:37:32.486Z'),
|
||||||
|
enabled: new Date('2023-01-26T09:37:32.508Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(timeToProduction).toBe(21);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,20 @@
|
|||||||
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { ICreateEnabledDates } from '../../../types/stores/project-stats-store-type';
|
||||||
|
|
||||||
|
const calculateTimeToProdForFeatures = (
|
||||||
|
items: ICreateEnabledDates[],
|
||||||
|
): number[] =>
|
||||||
|
items.map((item) => differenceInDays(item.enabled, item.created));
|
||||||
|
|
||||||
|
export const calculateAverageTimeToProd = (
|
||||||
|
items: ICreateEnabledDates[],
|
||||||
|
): number => {
|
||||||
|
const timeToProdPerFeature = calculateTimeToProdForFeatures(items);
|
||||||
|
if (timeToProdPerFeature.length) {
|
||||||
|
const sum = timeToProdPerFeature.reduce((acc, curr) => acc + curr, 0);
|
||||||
|
|
||||||
|
return Number((sum / Object.keys(items).length).toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
@ -1,216 +0,0 @@
|
|||||||
import { addDays, subDays } from 'date-fns';
|
|
||||||
import { IEvent } from 'lib/types';
|
|
||||||
import { TimeToProduction } from './time-to-production';
|
|
||||||
|
|
||||||
const modifyEventCreatedAt = (events: IEvent[], days: number): IEvent[] => {
|
|
||||||
return events.map((event) => {
|
|
||||||
const newEvent = { ...event };
|
|
||||||
newEvent.createdAt = addDays(newEvent.createdAt, days);
|
|
||||||
newEvent.id = newEvent.id + days;
|
|
||||||
return newEvent;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createEvent = (env: string, overrides: Partial<IEvent>) => {
|
|
||||||
return {
|
|
||||||
id: Math.floor(Math.random() * 1000),
|
|
||||||
type: 'feature-environment-enabled',
|
|
||||||
createdBy: 'Fredrik',
|
|
||||||
createdAt: new Date('2023-01-25T09:37:32.504Z'),
|
|
||||||
data: null,
|
|
||||||
preData: null,
|
|
||||||
tags: [],
|
|
||||||
featureName: 'average-prod-time',
|
|
||||||
project: 'average-time-to-prod',
|
|
||||||
environment: env,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const events = [
|
|
||||||
{
|
|
||||||
id: 65,
|
|
||||||
type: 'feature-environment-enabled',
|
|
||||||
createdBy: 'Fredrik',
|
|
||||||
createdAt: new Date('2023-01-25T09:37:32.504Z'),
|
|
||||||
data: null,
|
|
||||||
preData: null,
|
|
||||||
tags: [],
|
|
||||||
featureName: 'average-prod-time',
|
|
||||||
project: 'average-time-to-prod',
|
|
||||||
environment: 'default',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 66,
|
|
||||||
type: 'feature-environment-enabled',
|
|
||||||
createdBy: 'Fredrik',
|
|
||||||
createdAt: new Date('2023-01-31T09:37:32.506Z'),
|
|
||||||
data: null,
|
|
||||||
preData: null,
|
|
||||||
tags: [],
|
|
||||||
featureName: 'average-prod-time-2',
|
|
||||||
project: 'average-time-to-prod',
|
|
||||||
environment: 'default',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 67,
|
|
||||||
type: 'feature-environment-enabled',
|
|
||||||
createdBy: 'Fredrik',
|
|
||||||
createdAt: new Date('2023-01-26T09:37:32.508Z'),
|
|
||||||
data: null,
|
|
||||||
preData: null,
|
|
||||||
tags: [],
|
|
||||||
featureName: 'average-prod-time-3',
|
|
||||||
project: 'average-time-to-prod',
|
|
||||||
environment: 'default',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 68,
|
|
||||||
type: 'feature-environment-enabled',
|
|
||||||
createdBy: 'Fredrik',
|
|
||||||
createdAt: new Date('2023-02-02T09:37:32.509Z'),
|
|
||||||
data: null,
|
|
||||||
preData: null,
|
|
||||||
tags: [],
|
|
||||||
featureName: 'average-prod-time-4',
|
|
||||||
project: 'average-time-to-prod',
|
|
||||||
environment: 'default',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const environments = [
|
|
||||||
{
|
|
||||||
name: 'default',
|
|
||||||
type: 'production',
|
|
||||||
sortOrder: 1,
|
|
||||||
enabled: true,
|
|
||||||
protected: true,
|
|
||||||
projectApiTokenCount: 0,
|
|
||||||
projectEnabledToggleCount: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
name: 'average-prod-time',
|
|
||||||
description: null,
|
|
||||||
type: 'release',
|
|
||||||
project: 'average-time-to-prod',
|
|
||||||
stale: false,
|
|
||||||
createdAt: new Date('2022-12-05T09:37:32.483Z'),
|
|
||||||
lastSeenAt: null,
|
|
||||||
impressionData: false,
|
|
||||||
archivedAt: null,
|
|
||||||
archived: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'average-prod-time-4',
|
|
||||||
description: null,
|
|
||||||
type: 'release',
|
|
||||||
project: 'average-time-to-prod',
|
|
||||||
stale: false,
|
|
||||||
createdAt: new Date('2023-01-19T09:37:32.484Z'),
|
|
||||||
lastSeenAt: null,
|
|
||||||
impressionData: false,
|
|
||||||
archivedAt: null,
|
|
||||||
archived: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'average-prod-time-2',
|
|
||||||
description: null,
|
|
||||||
type: 'release',
|
|
||||||
project: 'average-time-to-prod',
|
|
||||||
stale: false,
|
|
||||||
createdAt: new Date('2023-01-19T09:37:32.484Z'),
|
|
||||||
lastSeenAt: null,
|
|
||||||
impressionData: false,
|
|
||||||
archivedAt: null,
|
|
||||||
archived: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'average-prod-time-3',
|
|
||||||
description: null,
|
|
||||||
type: 'release',
|
|
||||||
project: 'average-time-to-prod',
|
|
||||||
stale: false,
|
|
||||||
createdAt: new Date('2023-01-19T09:37:32.486Z'),
|
|
||||||
lastSeenAt: null,
|
|
||||||
impressionData: false,
|
|
||||||
archivedAt: null,
|
|
||||||
archived: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('calculate average time to production', () => {
|
|
||||||
test('should build a map of feature events', () => {
|
|
||||||
const projectStatus = new TimeToProduction(
|
|
||||||
features,
|
|
||||||
environments,
|
|
||||||
events,
|
|
||||||
);
|
|
||||||
|
|
||||||
const featureEvents = projectStatus.getFeatureEvents();
|
|
||||||
|
|
||||||
expect(Object.keys(featureEvents).length).toBe(4);
|
|
||||||
expect(featureEvents['average-prod-time'].createdAt).toBeTruthy();
|
|
||||||
expect(featureEvents['average-prod-time'].events).toBeInstanceOf(Array);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should calculate average correctly', () => {
|
|
||||||
const projectStatus = new TimeToProduction(
|
|
||||||
features,
|
|
||||||
environments,
|
|
||||||
events,
|
|
||||||
);
|
|
||||||
|
|
||||||
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
|
||||||
|
|
||||||
expect(timeToProduction).toBe(21);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should sort events by createdAt', () => {
|
|
||||||
const projectStatus = new TimeToProduction(features, environments, [
|
|
||||||
...modifyEventCreatedAt(events, 5),
|
|
||||||
...events,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const featureEvents = projectStatus.getFeatureEvents();
|
|
||||||
const sortedFeatureEvents =
|
|
||||||
projectStatus.sortFeatureEventsByCreatedAt(featureEvents);
|
|
||||||
|
|
||||||
const [firstEvent, secondEvent] =
|
|
||||||
sortedFeatureEvents['average-prod-time'].events;
|
|
||||||
|
|
||||||
const firstEventCreatedAt = new Date(firstEvent.createdAt);
|
|
||||||
const secondEventCreatedAt = new Date(secondEvent.createdAt);
|
|
||||||
|
|
||||||
expect(firstEventCreatedAt.getTime()).toBeLessThan(
|
|
||||||
secondEventCreatedAt.getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [firstEvent2, secondEvent2] =
|
|
||||||
sortedFeatureEvents['average-prod-time-2'].events;
|
|
||||||
|
|
||||||
const firstEventCreatedAt2 = new Date(firstEvent2.createdAt);
|
|
||||||
const secondEventCreatedAt2 = new Date(secondEvent2.createdAt);
|
|
||||||
|
|
||||||
expect(firstEventCreatedAt2.getTime()).toBeLessThan(
|
|
||||||
secondEventCreatedAt2.getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not count events that are development environments', () => {
|
|
||||||
const projectStatus = new TimeToProduction(features, environments, [
|
|
||||||
createEvent('development', {
|
|
||||||
createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10),
|
|
||||||
}),
|
|
||||||
createEvent('development', {
|
|
||||||
createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10),
|
|
||||||
}),
|
|
||||||
...events,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
|
||||||
expect(timeToProduction).toBe(21);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,121 +0,0 @@
|
|||||||
import { differenceInDays } from 'date-fns';
|
|
||||||
import { FeatureToggle, IEvent, IProjectEnvironment } from 'lib/types';
|
|
||||||
|
|
||||||
interface IFeatureTimeToProdCalculationMap {
|
|
||||||
[index: string]: IFeatureTimeToProdData;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IFeatureTimeToProdData {
|
|
||||||
createdAt: string;
|
|
||||||
events: IEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TimeToProduction {
|
|
||||||
private features: FeatureToggle[];
|
|
||||||
|
|
||||||
private productionEnvironments: IProjectEnvironment[];
|
|
||||||
|
|
||||||
private events: IEvent[];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
features: FeatureToggle[],
|
|
||||||
productionEnvironments: IProjectEnvironment[],
|
|
||||||
events: IEvent[],
|
|
||||||
) {
|
|
||||||
this.features = features;
|
|
||||||
this.productionEnvironments = productionEnvironments;
|
|
||||||
this.events = events;
|
|
||||||
}
|
|
||||||
|
|
||||||
calculateAverageTimeToProd(): number {
|
|
||||||
const featureEvents = this.getFeatureEvents();
|
|
||||||
const sortedFeatureEvents =
|
|
||||||
this.sortFeatureEventsByCreatedAt(featureEvents);
|
|
||||||
|
|
||||||
const timeToProdPerFeature =
|
|
||||||
this.calculateTimeToProdForFeatures(sortedFeatureEvents);
|
|
||||||
if (timeToProdPerFeature.length) {
|
|
||||||
const sum = timeToProdPerFeature.reduce(
|
|
||||||
(acc, curr) => acc + curr,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Number(
|
|
||||||
(sum / Object.keys(sortedFeatureEvents).length).toFixed(1),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFeatureEvents(): IFeatureTimeToProdCalculationMap {
|
|
||||||
return this.getProductionEvents(this.events).reduce((acc, event) => {
|
|
||||||
if (acc[event.featureName]) {
|
|
||||||
acc[event.featureName].events.push(event);
|
|
||||||
} else {
|
|
||||||
const foundFeature = this.features.find(
|
|
||||||
(feature) => feature.name === event.featureName,
|
|
||||||
);
|
|
||||||
acc[event.featureName] = { events: [event] };
|
|
||||||
acc[event.featureName].createdAt = foundFeature?.createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
getProductionEvents(events: IEvent[]): IEvent[] {
|
|
||||||
return events.filter((event) => {
|
|
||||||
const found = this.productionEnvironments.find(
|
|
||||||
(env) => env.name === event.environment,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (found) {
|
|
||||||
return found.type === 'production';
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
calculateTimeToProdForFeatures(
|
|
||||||
featureEvents: IFeatureTimeToProdCalculationMap,
|
|
||||||
): number[] {
|
|
||||||
return Object.keys(featureEvents).map((featureName) => {
|
|
||||||
const feature = featureEvents[featureName];
|
|
||||||
|
|
||||||
const earliestEvent = feature.events[0];
|
|
||||||
|
|
||||||
const createdAtDate = new Date(feature.createdAt);
|
|
||||||
const eventDate = new Date(earliestEvent.createdAt);
|
|
||||||
const diff = differenceInDays(eventDate, createdAtDate);
|
|
||||||
|
|
||||||
return diff;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sortFeatureEventsByCreatedAt(
|
|
||||||
featureEvents: IFeatureTimeToProdCalculationMap,
|
|
||||||
): IFeatureTimeToProdCalculationMap {
|
|
||||||
return Object.keys(featureEvents).reduce((acc, featureName) => {
|
|
||||||
const feature = featureEvents[featureName];
|
|
||||||
acc[featureName] = {
|
|
||||||
...feature,
|
|
||||||
events: feature.events.sort((a, b) => {
|
|
||||||
const aDate = new Date(a.createdAt);
|
|
||||||
const bDate = new Date(b.createdAt);
|
|
||||||
|
|
||||||
if (aDate > bDate) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (aDate < bDate) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
}
|
|
@ -8,7 +8,6 @@ import { projectSchema } from './project-schema';
|
|||||||
import NotFoundError from '../error/notfound-error';
|
import NotFoundError from '../error/notfound-error';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PROJECT,
|
DEFAULT_PROJECT,
|
||||||
FEATURE_ENVIRONMENT_ENABLED,
|
|
||||||
FeatureToggle,
|
FeatureToggle,
|
||||||
IAccountStore,
|
IAccountStore,
|
||||||
IEnvironmentStore,
|
IEnvironmentStore,
|
||||||
@ -54,7 +53,7 @@ import { arraysHaveSameItems } from '../util';
|
|||||||
import { GroupService } from './group-service';
|
import { GroupService } from './group-service';
|
||||||
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
|
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
|
||||||
import { FavoritesService } from './favorites-service';
|
import { FavoritesService } from './favorites-service';
|
||||||
import { TimeToProduction } from '../read-models/time-to-production/time-to-production';
|
import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-production/time-to-production';
|
||||||
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';
|
||||||
|
|
||||||
@ -688,42 +687,32 @@ export default class ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStatusUpdates(projectId: string): Promise<ICalculateStatus> {
|
async getStatusUpdates(projectId: string): Promise<ICalculateStatus> {
|
||||||
// Get all features for project with type release
|
|
||||||
const features = await this.featureToggleStore.getAll({
|
|
||||||
type: 'release',
|
|
||||||
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();
|
||||||
|
|
||||||
const [createdCurrentWindow, createdPastWindow] = await Promise.all([
|
const [
|
||||||
await this.featureToggleStore.getByDate({
|
createdCurrentWindow,
|
||||||
|
createdPastWindow,
|
||||||
|
archivedCurrentWindow,
|
||||||
|
archivedPastWindow,
|
||||||
|
] = await Promise.all([
|
||||||
|
await this.featureToggleStore.countByDate({
|
||||||
project: projectId,
|
project: projectId,
|
||||||
dateAccessor: 'created_at',
|
dateAccessor: 'created_at',
|
||||||
date: dateMinusThirtyDays,
|
date: dateMinusThirtyDays,
|
||||||
}),
|
}),
|
||||||
await this.featureToggleStore.getByDate({
|
await this.featureToggleStore.countByDate({
|
||||||
project: projectId,
|
project: projectId,
|
||||||
dateAccessor: 'created_at',
|
dateAccessor: 'created_at',
|
||||||
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||||
}),
|
}),
|
||||||
]);
|
await this.featureToggleStore.countByDate({
|
||||||
|
|
||||||
const [archivedCurrentWindow, archivedPastWindow] = await Promise.all([
|
|
||||||
await this.featureToggleStore.getByDate({
|
|
||||||
project: projectId,
|
project: projectId,
|
||||||
archived: true,
|
archived: true,
|
||||||
dateAccessor: 'archived_at',
|
dateAccessor: 'archived_at',
|
||||||
date: dateMinusThirtyDays,
|
date: dateMinusThirtyDays,
|
||||||
}),
|
}),
|
||||||
await this.featureToggleStore.getByDate({
|
await this.featureToggleStore.countByDate({
|
||||||
project: projectId,
|
project: projectId,
|
||||||
archived: true,
|
archived: true,
|
||||||
dateAccessor: 'archived_at',
|
dateAccessor: 'archived_at',
|
||||||
@ -755,33 +744,8 @@ export default class ProjectService {
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Get all project environments with type of production
|
const avgTimeToProdCurrentWindow = calculateAverageTimeToProd(
|
||||||
const productionEnvironments =
|
await this.projectStatsStore.getTimeToProdDates(projectId),
|
||||||
await this.environmentStore.getProjectEnvironments(projectId, {
|
|
||||||
type: 'production',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all events for features that correspond to feature toggle environment ON
|
|
||||||
// Filter out events that are not a production evironment
|
|
||||||
|
|
||||||
const allFeatures = [...features, ...archivedFeatures];
|
|
||||||
|
|
||||||
const eventsData = await this.eventStore.query([
|
|
||||||
{
|
|
||||||
op: 'forFeatures',
|
|
||||||
parameters: {
|
|
||||||
features: allFeatures.map((feature) => feature.name),
|
|
||||||
environments: productionEnvironments.map((env) => env.name),
|
|
||||||
type: FEATURE_ENVIRONMENT_ENABLED,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const currentWindowTimeToProdReadModel = new TimeToProduction(
|
|
||||||
allFeatures,
|
|
||||||
productionEnvironments,
|
|
||||||
eventsData,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectMembersAddedCurrentWindow =
|
const projectMembersAddedCurrentWindow =
|
||||||
@ -793,15 +757,13 @@ export default class ProjectService {
|
|||||||
return {
|
return {
|
||||||
projectId,
|
projectId,
|
||||||
updates: {
|
updates: {
|
||||||
avgTimeToProdCurrentWindow:
|
avgTimeToProdCurrentWindow,
|
||||||
currentWindowTimeToProdReadModel.calculateAverageTimeToProd(),
|
createdCurrentWindow,
|
||||||
createdCurrentWindow: createdCurrentWindow.length,
|
createdPastWindow,
|
||||||
createdPastWindow: createdPastWindow.length,
|
archivedCurrentWindow,
|
||||||
archivedCurrentWindow: archivedCurrentWindow.length,
|
archivedPastWindow,
|
||||||
archivedPastWindow: archivedPastWindow.length,
|
projectActivityCurrentWindow,
|
||||||
projectActivityCurrentWindow: projectActivityCurrentWindow,
|
projectActivityPastWindow,
|
||||||
projectActivityPastWindow: projectActivityPastWindow,
|
|
||||||
projectMembersAddedCurrentWindow:
|
|
||||||
projectMembersAddedCurrentWindow,
|
projectMembersAddedCurrentWindow,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -14,6 +14,10 @@ const flags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API,
|
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
projectStatusApiImprovements: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API_IMPROVEMENTS,
|
||||||
|
false,
|
||||||
|
),
|
||||||
newProjectOverview: parseEnvVarBoolean(
|
newProjectOverview: parseEnvVarBoolean(
|
||||||
process.env.NEW_PROJECT_OVERVIEW,
|
process.env.NEW_PROJECT_OVERVIEW,
|
||||||
false,
|
false,
|
||||||
|
@ -25,13 +25,13 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
|||||||
revive(featureName: string): Promise<FeatureToggle>;
|
revive(featureName: string): Promise<FeatureToggle>;
|
||||||
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
||||||
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
||||||
getByDate(queryModifiers: {
|
countByDate(queryModifiers: {
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
project?: string;
|
project?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
range?: string[];
|
range?: string[];
|
||||||
dateAccessor: string;
|
dateAccessor: string;
|
||||||
}): Promise<FeatureToggle[]>;
|
}): Promise<number>;
|
||||||
/**
|
/**
|
||||||
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
|
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
|
||||||
* @param featureName
|
* @param featureName
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import { IProjectStats } from 'lib/services/project-service';
|
import { IProjectStats } from 'lib/services/project-service';
|
||||||
|
|
||||||
|
export interface ICreateEnabledDates {
|
||||||
|
created: Date;
|
||||||
|
enabled: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IProjectStatsStore {
|
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[]>;
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ import { SegmentService } from '../../../lib/services/segment-service';
|
|||||||
import { GroupService } from '../../../lib/services/group-service';
|
import { GroupService } from '../../../lib/services/group-service';
|
||||||
import { FavoritesService } from '../../../lib/services';
|
import { FavoritesService } from '../../../lib/services';
|
||||||
import { FeatureEnvironmentEvent } from '../../../lib/types/events';
|
import { FeatureEnvironmentEvent } from '../../../lib/types/events';
|
||||||
import { subDays } from 'date-fns';
|
import { addDays, subDays } from 'date-fns';
|
||||||
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
|
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
|
||||||
|
|
||||||
let stores;
|
let stores;
|
||||||
@ -1310,6 +1310,94 @@ test('should calculate average time to production', async () => {
|
|||||||
expect(result.updates.avgTimeToProdCurrentWindow).toBe(11.4);
|
expect(result.updates.avgTimeToProdCurrentWindow).toBe(11.4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should calculate average time to production ignoring some items', async () => {
|
||||||
|
const project = {
|
||||||
|
id: 'average-time-to-prod-corner-cases',
|
||||||
|
name: 'average-time-to-prod',
|
||||||
|
mode: 'open' as const,
|
||||||
|
};
|
||||||
|
const makeEvent = (featureName: string) => ({
|
||||||
|
enabled: true,
|
||||||
|
project: project.id,
|
||||||
|
featureName,
|
||||||
|
environment: 'default',
|
||||||
|
createdBy: 'Fredrik',
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await projectService.createProject(project, user.id);
|
||||||
|
await stores.environmentStore.create({
|
||||||
|
name: 'customEnv',
|
||||||
|
type: 'development',
|
||||||
|
});
|
||||||
|
await environmentService.addEnvironmentToProject('customEnv', project.id);
|
||||||
|
|
||||||
|
// actual toggle we take for calculations
|
||||||
|
const toggle = { name: 'main-toggle' };
|
||||||
|
await featureToggleService.createFeatureToggle(project.id, toggle, user);
|
||||||
|
await updateFeature(toggle.name, {
|
||||||
|
created_at: subDays(new Date(), 20),
|
||||||
|
});
|
||||||
|
await stores.eventStore.store(
|
||||||
|
new FeatureEnvironmentEvent(makeEvent(toggle.name)),
|
||||||
|
);
|
||||||
|
// ignore events added after first enabled
|
||||||
|
await updateEventCreatedAt(addDays(new Date(), 1), toggle.name);
|
||||||
|
await stores.eventStore.store(
|
||||||
|
new FeatureEnvironmentEvent(makeEvent(toggle.name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ignore toggles enabled in non-prod envs
|
||||||
|
const devToggle = { name: 'dev-toggle' };
|
||||||
|
await featureToggleService.createFeatureToggle(project.id, devToggle, user);
|
||||||
|
await stores.eventStore.store(
|
||||||
|
new FeatureEnvironmentEvent({
|
||||||
|
...makeEvent(devToggle.name),
|
||||||
|
environment: 'customEnv',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ignore toggles from other projects
|
||||||
|
const otherProjectToggle = { name: 'other-project' };
|
||||||
|
await featureToggleService.createFeatureToggle(
|
||||||
|
'default',
|
||||||
|
otherProjectToggle,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
await stores.eventStore.store(
|
||||||
|
new FeatureEnvironmentEvent(makeEvent(otherProjectToggle.name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ignore non-release toggles
|
||||||
|
const nonReleaseToggle = { name: 'permission-toggle', type: 'permission' };
|
||||||
|
await featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
nonReleaseToggle,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
await stores.eventStore.store(
|
||||||
|
new FeatureEnvironmentEvent(makeEvent(nonReleaseToggle.name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ignore toggles with events before toggle creation time
|
||||||
|
const previouslyDeleteToggle = { name: 'previously-deleted' };
|
||||||
|
await featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
previouslyDeleteToggle,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
await stores.eventStore.store(
|
||||||
|
new FeatureEnvironmentEvent(makeEvent(previouslyDeleteToggle.name)),
|
||||||
|
);
|
||||||
|
await updateEventCreatedAt(
|
||||||
|
subDays(new Date(), 30),
|
||||||
|
previouslyDeleteToggle.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await projectService.getStatusUpdates(project.id);
|
||||||
|
expect(result.updates.avgTimeToProdCurrentWindow).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
const project = {
|
const project = {
|
||||||
id: 'features-created',
|
id: 'features-created',
|
||||||
|
@ -213,13 +213,13 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return Promise.resolve(newVariants);
|
return Promise.resolve(newVariants);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByDate(queryModifiers: {
|
async countByDate(queryModifiers: {
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
project?: string;
|
project?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
range?: string[];
|
range?: string[];
|
||||||
dateAccessor: string;
|
dateAccessor: string;
|
||||||
}): Promise<FeatureToggle[]> {
|
}): Promise<number> {
|
||||||
return this.features.filter((feature) => {
|
return this.features.filter((feature) => {
|
||||||
if (feature.archived === queryModifiers.archived) {
|
if (feature.archived === queryModifiers.archived) {
|
||||||
return true;
|
return true;
|
||||||
@ -245,7 +245,7 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
}).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
dropAllVariants(): Promise<void> {
|
dropAllVariants(): Promise<void> {
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { IProjectStats } from 'lib/services/project-service';
|
import { IProjectStats } from 'lib/services/project-service';
|
||||||
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
import {
|
||||||
|
ICreateEnabledDates,
|
||||||
|
IProjectStatsStore,
|
||||||
|
} from 'lib/types/stores/project-stats-store-type';
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
|
||||||
export default class FakeProjectStatsStore implements IProjectStatsStore {
|
export default class FakeProjectStatsStore implements IProjectStatsStore {
|
||||||
@ -13,4 +16,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore {
|
|||||||
getProjectStats(projectId: string): Promise<IProjectStats> {
|
getProjectStats(projectId: string): Promise<IProjectStats> {
|
||||||
throw new Error('not implemented');
|
throw new Error('not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTimeToProdDates(): Promise<ICreateEnabledDates[]> {
|
||||||
|
throw new Error('not implemented');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user