mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
Feat/project status monthly (#2986)
This PR takes the project status API a step further by adding the capability of providing a date to control the selection. We are currently making calculations based on a gliding 30 day window, updated once a day. The initial database structure and method for updating the UI is outlined in this PR.
This commit is contained in:
parent
b80e84b438
commit
d8a250dc9c
@ -17,7 +17,48 @@ const EVENT_COLUMNS = [
|
||||
'feature_name',
|
||||
'project',
|
||||
'environment',
|
||||
];
|
||||
] as const;
|
||||
|
||||
export type IQueryOperations =
|
||||
| IWhereOperation
|
||||
| IBeforeDateOperation
|
||||
| IBetweenDatesOperation
|
||||
| IForFeaturesOperation;
|
||||
|
||||
interface IWhereOperation {
|
||||
op: 'where';
|
||||
parameters: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface IBeforeDateOperation {
|
||||
op: 'beforeDate';
|
||||
parameters: {
|
||||
dateAccessor: string;
|
||||
date: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface IBetweenDatesOperation {
|
||||
op: 'betweenDate';
|
||||
parameters: {
|
||||
dateAccessor: string;
|
||||
range: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface IForFeaturesOperation {
|
||||
op: 'forFeatures';
|
||||
parameters: IForFeaturesParams;
|
||||
}
|
||||
|
||||
interface IForFeaturesParams {
|
||||
type: string;
|
||||
projectId: string;
|
||||
environments: string[];
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface IEventTable {
|
||||
id: number;
|
||||
@ -120,25 +161,77 @@ class EventStore extends AnyEventEmitter implements IEventStore {
|
||||
return present;
|
||||
}
|
||||
|
||||
async getForFeatures(
|
||||
features: string[],
|
||||
environments: string[],
|
||||
query: { type: string; projectId: string },
|
||||
): Promise<IEvent[]> {
|
||||
async query(operations: IQueryOperations[]): Promise<IEvent[]> {
|
||||
try {
|
||||
const rows = await this.db
|
||||
.select(EVENT_COLUMNS)
|
||||
.from(TABLE)
|
||||
.where({ type: query.type, project: query.projectId })
|
||||
.whereIn('feature_name', features)
|
||||
.whereIn('environment', environments);
|
||||
let query: Knex.QueryBuilder = this.select();
|
||||
|
||||
operations.forEach((operation) => {
|
||||
if (operation.op === 'where') {
|
||||
query = this.where(query, operation.parameters);
|
||||
}
|
||||
|
||||
if (operation.op === 'forFeatures') {
|
||||
query = this.forFeatures(query, operation.parameters);
|
||||
}
|
||||
|
||||
if (operation.op === 'beforeDate') {
|
||||
query = this.beforeDate(query, operation.parameters);
|
||||
}
|
||||
|
||||
if (operation.op === 'betweenDate') {
|
||||
query = this.betweenDate(query, operation.parameters);
|
||||
}
|
||||
});
|
||||
|
||||
const rows = await query;
|
||||
return rows.map(this.rowToEvent);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
where(
|
||||
query: Knex.QueryBuilder,
|
||||
parameters: { [key: string]: string },
|
||||
): Knex.QueryBuilder {
|
||||
return query.where(parameters);
|
||||
}
|
||||
|
||||
beforeDate(
|
||||
query: Knex.QueryBuilder,
|
||||
parameters: { dateAccessor: string; date: string },
|
||||
): Knex.QueryBuilder {
|
||||
return query.andWhere(parameters.dateAccessor, '>=', parameters.date);
|
||||
}
|
||||
|
||||
betweenDate(
|
||||
query: Knex.QueryBuilder,
|
||||
parameters: { dateAccessor: string; range: string[] },
|
||||
): Knex.QueryBuilder {
|
||||
if (parameters.range && parameters.range.length === 2) {
|
||||
return query.andWhereBetween(parameters.dateAccessor, [
|
||||
parameters.range[0],
|
||||
parameters.range[1],
|
||||
]);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
select(): Knex.QueryBuilder {
|
||||
return this.db.select(EVENT_COLUMNS).from(TABLE);
|
||||
}
|
||||
|
||||
forFeatures(
|
||||
query: Knex.QueryBuilder,
|
||||
parameters: IForFeaturesParams,
|
||||
): Knex.QueryBuilder {
|
||||
return query
|
||||
.where({ type: parameters.type, project: parameters.projectId })
|
||||
.whereIn('feature_name', parameters.features)
|
||||
.whereIn('environment', parameters.environments);
|
||||
}
|
||||
|
||||
async get(key: number): Promise<IEvent> {
|
||||
const row = await this.db(TABLE).where({ id: key }).first();
|
||||
return this.rowToEvent(row);
|
||||
|
@ -111,6 +111,35 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
return rows.map(this.rowToFeature);
|
||||
}
|
||||
|
||||
async getByDate(queryModifiers: {
|
||||
archived?: boolean;
|
||||
project?: string;
|
||||
date?: string;
|
||||
range?: string[];
|
||||
dateAccessor: string;
|
||||
}): Promise<FeatureToggle[]> {
|
||||
const { project, archived, dateAccessor } = queryModifiers;
|
||||
let query = this.db
|
||||
.select(FEATURE_COLUMNS)
|
||||
.from(TABLE)
|
||||
.where({ project })
|
||||
.modify(FeatureToggleStore.filterByArchived, archived);
|
||||
|
||||
if (queryModifiers.date) {
|
||||
query.andWhere(dateAccessor, '>=', queryModifiers.date);
|
||||
}
|
||||
|
||||
if (queryModifiers.range && queryModifiers.range.length === 2) {
|
||||
query.andWhereBetween(dateAccessor, [
|
||||
queryModifiers.range[0],
|
||||
queryModifiers.range[1],
|
||||
]);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
return rows.map(this.rowToFeature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get projectId from feature filtered by name. Used by Rbac middleware
|
||||
* @deprecated
|
||||
|
@ -35,6 +35,7 @@ import { PublicSignupTokenStore } from './public-signup-token-store';
|
||||
import { FavoriteFeaturesStore } from './favorite-features-store';
|
||||
import { FavoriteProjectsStore } from './favorite-projects-store';
|
||||
import { AccountStore } from './account-store';
|
||||
import ProjectStatsStore from './project-stats-store';
|
||||
|
||||
export const createStores = (
|
||||
config: IUnleashConfig,
|
||||
@ -113,6 +114,7 @@ export const createStores = (
|
||||
eventBus,
|
||||
getLogger,
|
||||
),
|
||||
projectStatsStore: new ProjectStatsStore(db, eventBus, getLogger),
|
||||
};
|
||||
};
|
||||
|
||||
|
52
src/lib/db/project-stats-store.ts
Normal file
52
src/lib/db/project-stats-store.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
|
||||
import metricsHelper from '../util/metrics-helper';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import EventEmitter from 'events';
|
||||
import { IProjectStats } from 'lib/services/project-service';
|
||||
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||
|
||||
const TABLE = 'project_stats';
|
||||
|
||||
class ProjectStatsStore implements IProjectStatsStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
private timer: Function;
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('project-stats-store.ts');
|
||||
this.timer = (action) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'project_stats',
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
async updateProjectStats(
|
||||
projectId: string,
|
||||
status: IProjectStats,
|
||||
): Promise<void> {
|
||||
await this.db(TABLE)
|
||||
.insert({
|
||||
avg_time_to_prod_current_window:
|
||||
status.avgTimeToProdCurrentWindow,
|
||||
avg_time_to_prod_past_window: status.avgTimeToProdPastWindow,
|
||||
project: projectId,
|
||||
features_created_current_window: status.createdCurrentWindow,
|
||||
features_created_past_window: status.createdPastWindow,
|
||||
features_archived_current_window: status.archivedCurrentWindow,
|
||||
features_archived_past_window: status.archivedPastWindow,
|
||||
project_changes_current_window:
|
||||
status.projectActivityCurrentWindow,
|
||||
project_changes_past_window: status.projectActivityPastWindow,
|
||||
})
|
||||
.onConflict('project')
|
||||
.merge();
|
||||
}
|
||||
}
|
||||
|
||||
export default ProjectStatsStore;
|
@ -1,6 +1,6 @@
|
||||
import { addDays, subDays } from 'date-fns';
|
||||
import { IEvent } from 'lib/types';
|
||||
import { ProjectStatus } from './project-status';
|
||||
import { TimeToProduction } from './time-to-production';
|
||||
|
||||
const modifyEventCreatedAt = (events: IEvent[], days: number): IEvent[] => {
|
||||
return events.map((event) => {
|
||||
@ -97,7 +97,7 @@ const features = [
|
||||
type: 'release',
|
||||
project: 'average-time-to-prod',
|
||||
stale: false,
|
||||
createdAt: new Date('2023-01-19T09:37:32.483Z'),
|
||||
createdAt: new Date('2022-12-05T09:37:32.483Z'),
|
||||
lastSeenAt: null,
|
||||
impressionData: false,
|
||||
archivedAt: null,
|
||||
@ -143,7 +143,11 @@ const features = [
|
||||
|
||||
describe('calculate average time to production', () => {
|
||||
test('should build a map of feature events', () => {
|
||||
const projectStatus = new ProjectStatus(features, environments, events);
|
||||
const projectStatus = new TimeToProduction(
|
||||
features,
|
||||
environments,
|
||||
events,
|
||||
);
|
||||
|
||||
const featureEvents = projectStatus.getFeatureEvents();
|
||||
|
||||
@ -153,15 +157,19 @@ describe('calculate average time to production', () => {
|
||||
});
|
||||
|
||||
test('should calculate average correctly', () => {
|
||||
const projectStatus = new ProjectStatus(features, environments, events);
|
||||
const projectStatus = new TimeToProduction(
|
||||
features,
|
||||
environments,
|
||||
events,
|
||||
);
|
||||
|
||||
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
||||
|
||||
expect(timeToProduction).toBe(9.75);
|
||||
expect(timeToProduction).toBe(21);
|
||||
});
|
||||
|
||||
test('should sort events by createdAt', () => {
|
||||
const projectStatus = new ProjectStatus(features, environments, [
|
||||
const projectStatus = new TimeToProduction(features, environments, [
|
||||
...modifyEventCreatedAt(events, 5),
|
||||
...events,
|
||||
]);
|
||||
@ -192,7 +200,7 @@ describe('calculate average time to production', () => {
|
||||
});
|
||||
|
||||
test('should not count events that are development environments', () => {
|
||||
const projectStatus = new ProjectStatus(features, environments, [
|
||||
const projectStatus = new TimeToProduction(features, environments, [
|
||||
createEvent('development', {
|
||||
createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10),
|
||||
}),
|
||||
@ -203,6 +211,6 @@ describe('calculate average time to production', () => {
|
||||
]);
|
||||
|
||||
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
||||
expect(timeToProduction).toBe(9.75);
|
||||
expect(timeToProduction).toBe(21);
|
||||
});
|
||||
});
|
@ -10,7 +10,7 @@ interface IFeatureTimeToProdData {
|
||||
events: IEvent[];
|
||||
}
|
||||
|
||||
export class ProjectStatus {
|
||||
export class TimeToProduction {
|
||||
private features: FeatureToggle[];
|
||||
|
||||
private productionEnvironments: IProjectEnvironment[];
|
||||
@ -31,16 +31,25 @@ export class ProjectStatus {
|
||||
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,
|
||||
);
|
||||
|
||||
const sum = timeToProdPerFeature.reduce((acc, curr) => acc + curr, 0);
|
||||
return Number(
|
||||
(sum / Object.keys(sortedFeatureEvents).length).toFixed(1),
|
||||
);
|
||||
}
|
||||
|
||||
return sum / Object.keys(sortedFeatureEvents).length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
getFeatureEvents(): IFeatureTimeToProdCalculationMap {
|
||||
return this.filterEvents(this.events).reduce((acc, event) => {
|
||||
return this.getProductionEvents(this.events).reduce((acc, event) => {
|
||||
if (acc[event.featureName]) {
|
||||
acc[event.featureName].events.push(event);
|
||||
} else {
|
||||
@ -55,7 +64,7 @@ export class ProjectStatus {
|
||||
}, {});
|
||||
}
|
||||
|
||||
filterEvents(events: IEvent[]): IEvent[] {
|
||||
getProductionEvents(events: IEvent[]): IEvent[] {
|
||||
return events.filter((event) => {
|
||||
const found = this.productionEnvironments.find(
|
||||
(env) => env.name === event.environment,
|
||||
@ -74,6 +83,7 @@ export class ProjectStatus {
|
||||
): number[] {
|
||||
return Object.keys(featureEvents).map((featureName) => {
|
||||
const feature = featureEvents[featureName];
|
||||
|
||||
const earliestEvent = feature.events[0];
|
||||
|
||||
const createdAtDate = new Date(feature.createdAt);
|
@ -164,7 +164,7 @@ export const createServices = (
|
||||
if (config.flagResolver.isEnabled('projectStatusApi')) {
|
||||
const ONE_DAY = 1440;
|
||||
schedulerService.schedule(
|
||||
projectService.statusJob.bind(projectHealthService),
|
||||
projectService.statusJob.bind(projectService),
|
||||
minutesToMilliseconds(ONE_DAY),
|
||||
);
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { subDays } from 'date-fns';
|
||||
import User, { IUser } from '../types/user';
|
||||
import { AccessService } from './access-service';
|
||||
import NameExistsError from '../error/name-exists-error';
|
||||
@ -47,7 +48,8 @@ import { arraysHaveSameItems } from '../util/arraysHaveSameItems';
|
||||
import { GroupService } from './group-service';
|
||||
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
|
||||
import { FavoritesService } from './favorites-service';
|
||||
import { ProjectStatus } from '../read-models/project-status/project-status';
|
||||
import { TimeToProduction } from '../read-models/time-to-production/time-to-production';
|
||||
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||
|
||||
const getCreatedBy = (user: IUser) => user.email || user.username;
|
||||
|
||||
@ -57,6 +59,25 @@ export interface AccessWithRoles {
|
||||
groups: IGroupModelWithProjectRole[];
|
||||
}
|
||||
|
||||
type Days = number;
|
||||
type Count = number;
|
||||
|
||||
export interface IProjectStats {
|
||||
avgTimeToProdCurrentWindow: Days;
|
||||
avgTimeToProdPastWindow: Days;
|
||||
createdCurrentWindow: Count;
|
||||
createdPastWindow: Count;
|
||||
archivedCurrentWindow: Count;
|
||||
archivedPastWindow: Count;
|
||||
projectActivityCurrentWindow: Count;
|
||||
projectActivityPastWindow: Count;
|
||||
}
|
||||
|
||||
interface ICalculateStatus {
|
||||
projectId: string;
|
||||
updates: IProjectStats;
|
||||
}
|
||||
|
||||
export default class ProjectService {
|
||||
private store: IProjectStore;
|
||||
|
||||
@ -84,6 +105,8 @@ export default class ProjectService {
|
||||
|
||||
private favoritesService: FavoritesService;
|
||||
|
||||
private projectStatsStore: IProjectStatsStore;
|
||||
|
||||
constructor(
|
||||
{
|
||||
projectStore,
|
||||
@ -94,6 +117,7 @@ export default class ProjectService {
|
||||
featureEnvironmentStore,
|
||||
featureTagStore,
|
||||
accountStore,
|
||||
projectStatsStore,
|
||||
}: Pick<
|
||||
IUnleashStores,
|
||||
| 'projectStore'
|
||||
@ -104,6 +128,7 @@ export default class ProjectService {
|
||||
| 'featureEnvironmentStore'
|
||||
| 'featureTagStore'
|
||||
| 'accountStore'
|
||||
| 'projectStatsStore'
|
||||
>,
|
||||
config: IUnleashConfig,
|
||||
accessService: AccessService,
|
||||
@ -123,6 +148,7 @@ export default class ProjectService {
|
||||
this.tagStore = featureTagStore;
|
||||
this.accountStore = accountStore;
|
||||
this.groupService = groupService;
|
||||
this.projectStatsStore = projectStatsStore;
|
||||
this.logger = config.getLogger('services/project-service.js');
|
||||
}
|
||||
|
||||
@ -596,20 +622,82 @@ export default class ProjectService {
|
||||
async statusJob(): Promise<void> {
|
||||
const projects = await this.store.getAll();
|
||||
|
||||
const statusUpdates = await Promise.all(
|
||||
projects.map((project) => this.getStatusUpdates(project.id)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
projects.map((project) =>
|
||||
this.calculateAverageTimeToProd(project.id),
|
||||
),
|
||||
statusUpdates.map((statusUpdate) => {
|
||||
return this.projectStatsStore.updateProjectStats(
|
||||
statusUpdate.projectId,
|
||||
statusUpdate.updates,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async calculateAverageTimeToProd(projectId: string): Promise<number> {
|
||||
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 dateMinusThirtyDays = subDays(new Date(), 30).toISOString();
|
||||
const dateMinusSixtyDays = subDays(new Date(), 60).toISOString();
|
||||
|
||||
const [createdCurrentWindow, createdPastWindow] = await Promise.all([
|
||||
await this.featureToggleStore.getByDate({
|
||||
project: projectId,
|
||||
dateAccessor: 'created_at',
|
||||
date: dateMinusThirtyDays,
|
||||
}),
|
||||
await this.featureToggleStore.getByDate({
|
||||
project: projectId,
|
||||
dateAccessor: 'created_at',
|
||||
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||
}),
|
||||
]);
|
||||
|
||||
const [archivedCurrentWindow, archivedPastWindow] = await Promise.all([
|
||||
await this.featureToggleStore.getByDate({
|
||||
project: projectId,
|
||||
archived: true,
|
||||
dateAccessor: 'archived_at',
|
||||
date: dateMinusThirtyDays,
|
||||
}),
|
||||
await this.featureToggleStore.getByDate({
|
||||
project: projectId,
|
||||
archived: true,
|
||||
dateAccessor: 'archived_at',
|
||||
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||
}),
|
||||
]);
|
||||
|
||||
const [projectActivityCurrentWindow, projectActivityPastWindow] =
|
||||
await Promise.all([
|
||||
this.eventStore.query([
|
||||
{ op: 'where', parameters: { project: projectId } },
|
||||
{
|
||||
op: 'beforeDate',
|
||||
parameters: {
|
||||
dateAccessor: 'created_at',
|
||||
date: dateMinusThirtyDays,
|
||||
},
|
||||
},
|
||||
]),
|
||||
this.eventStore.query([
|
||||
{ op: 'where', parameters: { project: projectId } },
|
||||
{
|
||||
op: 'betweenDate',
|
||||
parameters: {
|
||||
dateAccessor: 'created_at',
|
||||
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||
},
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
// Get all project environments with type of production
|
||||
const productionEnvironments =
|
||||
await this.environmentStore.getProjectEnvironments(projectId, {
|
||||
@ -618,21 +706,73 @@ export default class ProjectService {
|
||||
|
||||
// Get all events for features that correspond to feature toggle environment ON
|
||||
// Filter out events that are not a production evironment
|
||||
const events = await this.eventStore.getForFeatures(
|
||||
features.map((feature) => feature.name),
|
||||
productionEnvironments.map((env) => env.name),
|
||||
{
|
||||
type: FEATURE_ENVIRONMENT_ENABLED,
|
||||
projectId,
|
||||
},
|
||||
);
|
||||
|
||||
const projectStatus = new ProjectStatus(
|
||||
const eventsCurrentWindow = await this.eventStore.query([
|
||||
{
|
||||
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([
|
||||
{
|
||||
op: 'forFeatures',
|
||||
parameters: {
|
||||
features: features.map((feature) => feature.name),
|
||||
environments: productionEnvironments.map((env) => env.name),
|
||||
type: FEATURE_ENVIRONMENT_ENABLED,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'betweenDate',
|
||||
parameters: {
|
||||
dateAccessor: 'created_at',
|
||||
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const currentWindowTimeToProdReadModel = new TimeToProduction(
|
||||
features,
|
||||
productionEnvironments,
|
||||
events,
|
||||
eventsCurrentWindow,
|
||||
);
|
||||
return projectStatus.calculateAverageTimeToProd();
|
||||
|
||||
const pastWindowTimeToProdReadModel = new TimeToProduction(
|
||||
features,
|
||||
productionEnvironments,
|
||||
eventsPastWindow,
|
||||
);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
updates: {
|
||||
avgTimeToProdCurrentWindow:
|
||||
currentWindowTimeToProdReadModel.calculateAverageTimeToProd(),
|
||||
avgTimeToProdPastWindow:
|
||||
pastWindowTimeToProdReadModel.calculateAverageTimeToProd(),
|
||||
createdCurrentWindow: createdCurrentWindow.length,
|
||||
createdPastWindow: createdPastWindow.length,
|
||||
archivedCurrentWindow: archivedCurrentWindow.length,
|
||||
archivedPastWindow: archivedPastWindow.length,
|
||||
projectActivityCurrentWindow:
|
||||
projectActivityCurrentWindow.length,
|
||||
projectActivityPastWindow: projectActivityPastWindow.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getProjectOverview(
|
||||
|
@ -31,6 +31,7 @@ import { IPublicSignupTokenStore } from './stores/public-signup-token-store';
|
||||
import { IFavoriteFeaturesStore } from './stores/favorite-features';
|
||||
import { IFavoriteProjectsStore } from './stores/favorite-projects';
|
||||
import { IAccountStore } from './stores/account-store';
|
||||
import { IProjectStatsStore } from './stores/project-stats-store-type';
|
||||
|
||||
export interface IUnleashStores {
|
||||
accessStore: IAccessStore;
|
||||
@ -66,6 +67,7 @@ export interface IUnleashStores {
|
||||
publicSignupTokenStore: IPublicSignupTokenStore;
|
||||
favoriteFeaturesStore: IFavoriteFeaturesStore;
|
||||
favoriteProjectsStore: IFavoriteProjectsStore;
|
||||
projectStatsStore: IProjectStatsStore;
|
||||
}
|
||||
|
||||
export {
|
||||
|
@ -2,6 +2,7 @@ import { IBaseEvent, IEvent } from '../events';
|
||||
import { Store } from './store';
|
||||
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
|
||||
import EventEmitter from 'events';
|
||||
import { IQueryOperations } from 'lib/db/event-store';
|
||||
|
||||
export interface IEventStore extends Store<IEvent, number>, EventEmitter {
|
||||
store(event: IBaseEvent): Promise<void>;
|
||||
@ -10,9 +11,5 @@ export interface IEventStore extends Store<IEvent, number>, EventEmitter {
|
||||
count(): Promise<number>;
|
||||
filteredCount(search: SearchEventsSchema): Promise<number>;
|
||||
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
|
||||
getForFeatures(
|
||||
features: string[],
|
||||
environments: string[],
|
||||
query: { type: string; projectId: string },
|
||||
): Promise<IEvent[]>;
|
||||
query(operations: IQueryOperations[]): Promise<IEvent[]>;
|
||||
}
|
||||
|
@ -18,6 +18,13 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
||||
revive(featureName: string): Promise<FeatureToggle>;
|
||||
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
||||
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
||||
getByDate(queryModifiers: {
|
||||
archived?: boolean;
|
||||
project?: string;
|
||||
date?: string;
|
||||
range?: string[];
|
||||
dateAccessor: string;
|
||||
}): Promise<FeatureToggle[]>;
|
||||
/**
|
||||
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
|
||||
* @param featureName
|
||||
|
5
src/lib/types/stores/project-stats-store-type.ts
Normal file
5
src/lib/types/stores/project-stats-store-type.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { IProjectStats } from 'lib/services/project-service';
|
||||
|
||||
export interface IProjectStatsStore {
|
||||
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
|
||||
}
|
28
src/migrations/20230125065315-project-stats-table.js
Normal file
28
src/migrations/20230125065315-project-stats-table.js
Normal file
@ -0,0 +1,28 @@
|
||||
exports.up = function (db, cb) {
|
||||
db.runSql(
|
||||
`CREATE TABLE IF NOT EXISTS project_stats (
|
||||
project VARCHAR(255) NOT NULL,
|
||||
avg_time_to_prod_current_window FLOAT DEFAULT 0,
|
||||
avg_time_to_prod_past_window FLOAT DEFAULT 0,
|
||||
project_changes_current_window INTEGER DEFAULT 0,
|
||||
project_changes_past_window INTEGER DEFAULT 0,
|
||||
features_created_current_window INTEGER DEFAULT 0,
|
||||
features_created_past_window INTEGER DEFAULT 0,
|
||||
features_archived_current_window INTEGER DEFAULT 0,
|
||||
features_archived_past_window INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (project) references projects(id) ON DELETE CASCADE,
|
||||
UNIQUE(project)
|
||||
);
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
DROP TABLE project_status;
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
@ -43,6 +43,7 @@ process.nextTick(async () => {
|
||||
maintenance: false,
|
||||
featuresExportImport: true,
|
||||
newProjectOverview: true,
|
||||
projectStatusApi: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
@ -13,7 +13,7 @@ import { SegmentService } from '../../../lib/services/segment-service';
|
||||
import { GroupService } from '../../../lib/services/group-service';
|
||||
import { FavoritesService } from '../../../lib/services';
|
||||
import { FeatureEnvironmentEvent } from '../../../lib/types/events';
|
||||
import { addDays } from 'date-fns';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
let stores;
|
||||
let db: ITestDb;
|
||||
@ -1057,13 +1057,20 @@ test('should only count active feature toggles for project', async () => {
|
||||
expect(theProject?.featureCount).toBe(1);
|
||||
});
|
||||
|
||||
const updateEventCreatedAt = async (days: number, featureName: string) => {
|
||||
await db.rawDatabase
|
||||
const updateEventCreatedAt = async (date: Date, featureName: string) => {
|
||||
return db.rawDatabase
|
||||
.table('events')
|
||||
.update({ created_at: addDays(new Date(), days) })
|
||||
.update({ created_at: date })
|
||||
.where({ feature_name: featureName });
|
||||
};
|
||||
|
||||
const updateFeature = async (featureName: string, update: any) => {
|
||||
return db.rawDatabase
|
||||
.table('features')
|
||||
.update(update)
|
||||
.where({ name: featureName });
|
||||
};
|
||||
|
||||
test('should calculate average time to production', async () => {
|
||||
const project = {
|
||||
id: 'average-time-to-prod',
|
||||
@ -1077,6 +1084,7 @@ test('should calculate average time to production', async () => {
|
||||
{ name: 'average-prod-time-2' },
|
||||
{ name: 'average-prod-time-3' },
|
||||
{ name: 'average-prod-time-4' },
|
||||
{ name: 'average-prod-time-5' },
|
||||
];
|
||||
|
||||
const featureToggles = await Promise.all(
|
||||
@ -1104,11 +1112,103 @@ test('should calculate average time to production', async () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await updateEventCreatedAt(6, 'average-prod-time');
|
||||
await updateEventCreatedAt(12, 'average-prod-time-2');
|
||||
await updateEventCreatedAt(7, 'average-prod-time-3');
|
||||
await updateEventCreatedAt(14, 'average-prod-time-4');
|
||||
await updateEventCreatedAt(subDays(new Date(), 31), 'average-prod-time-5');
|
||||
|
||||
const result = await projectService.calculateAverageTimeToProd(project.id);
|
||||
expect(result).toBe(9.75);
|
||||
await Promise.all(
|
||||
featureToggles.map((toggle) =>
|
||||
updateFeature(toggle.name, { created_at: subDays(new Date(), 15) }),
|
||||
),
|
||||
);
|
||||
|
||||
await updateFeature('average-prod-time-5', {
|
||||
created_at: subDays(new Date(), 33),
|
||||
});
|
||||
|
||||
const result = await projectService.getStatusUpdates(project.id);
|
||||
expect(result.updates.avgTimeToProdCurrentWindow).toBe(14);
|
||||
expect(result.updates.avgTimeToProdPastWindow).toBe(1);
|
||||
});
|
||||
|
||||
test('should get correct amount of features created in current and past window', async () => {
|
||||
const project = {
|
||||
id: 'features-created',
|
||||
name: 'features-created',
|
||||
};
|
||||
|
||||
await projectService.createProject(project, user.id);
|
||||
|
||||
const toggles = [
|
||||
{ name: 'features-created' },
|
||||
{ name: 'features-created-2' },
|
||||
{ name: 'features-created-3' },
|
||||
{ name: 'features-created-4' },
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
toggles.map((toggle) => {
|
||||
return featureToggleService.createFeatureToggle(
|
||||
project.id,
|
||||
toggle,
|
||||
user,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
updateFeature(toggles[2].name, { created_at: subDays(new Date(), 31) }),
|
||||
updateFeature(toggles[3].name, { created_at: subDays(new Date(), 31) }),
|
||||
]);
|
||||
|
||||
const result = await projectService.getStatusUpdates(project.id);
|
||||
expect(result.updates.createdCurrentWindow).toBe(2);
|
||||
expect(result.updates.createdPastWindow).toBe(2);
|
||||
});
|
||||
|
||||
test('should get correct amount of features archived in current and past window', async () => {
|
||||
const project = {
|
||||
id: 'features-archived',
|
||||
name: 'features-archived',
|
||||
};
|
||||
|
||||
await projectService.createProject(project, user.id);
|
||||
|
||||
const toggles = [
|
||||
{ name: 'features-archived' },
|
||||
{ name: 'features-archived-2' },
|
||||
{ name: 'features-archived-3' },
|
||||
{ name: 'features-archived-4' },
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
toggles.map((toggle) => {
|
||||
return featureToggleService.createFeatureToggle(
|
||||
project.id,
|
||||
toggle,
|
||||
user,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
updateFeature(toggles[0].name, {
|
||||
archived_at: new Date(),
|
||||
archived: true,
|
||||
}),
|
||||
updateFeature(toggles[1].name, {
|
||||
archived_at: new Date(),
|
||||
archived: true,
|
||||
}),
|
||||
updateFeature(toggles[2].name, {
|
||||
archived_at: subDays(new Date(), 31),
|
||||
archived: true,
|
||||
}),
|
||||
updateFeature(toggles[3].name, {
|
||||
archived_at: subDays(new Date(), 31),
|
||||
archived: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await projectService.getStatusUpdates(project.id);
|
||||
expect(result.updates.archivedCurrentWindow).toBe(2);
|
||||
expect(result.updates.archivedPastWindow).toBe(2);
|
||||
});
|
||||
|
6
src/test/fixtures/fake-event-store.ts
vendored
6
src/test/fixtures/fake-event-store.ts
vendored
@ -1,6 +1,7 @@
|
||||
import { IEventStore } from '../../lib/types/stores/event-store';
|
||||
import { IEvent } from '../../lib/types/events';
|
||||
import { AnyEventEmitter } from '../../lib/util/anyEventEmitter';
|
||||
import { IQueryOperations } from 'lib/db/event-store';
|
||||
|
||||
class FakeEventStore extends AnyEventEmitter implements IEventStore {
|
||||
events: IEvent[];
|
||||
@ -80,6 +81,11 @@ class FakeEventStore extends AnyEventEmitter implements IEventStore {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async query(operations: IQueryOperations[]): Promise<IEvent[]> {
|
||||
if (operations) return [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FakeEventStore;
|
||||
|
35
src/test/fixtures/fake-feature-toggle-store.ts
vendored
35
src/test/fixtures/fake-feature-toggle-store.ts
vendored
@ -173,6 +173,41 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||
return Promise.resolve(newVariants);
|
||||
}
|
||||
|
||||
async getByDate(queryModifiers: {
|
||||
archived?: boolean;
|
||||
project?: string;
|
||||
date?: string;
|
||||
range?: string[];
|
||||
dateAccessor: string;
|
||||
}): Promise<FeatureToggle[]> {
|
||||
return this.features.filter((feature) => {
|
||||
if (feature.archived === queryModifiers.archived) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (feature.project === queryModifiers.project) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
new Date(feature[queryModifiers.dateAccessor]).getTime() >=
|
||||
new Date(queryModifiers.date).getTime()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const featureDate = new Date(
|
||||
feature[queryModifiers.dateAccessor],
|
||||
).getTime();
|
||||
if (
|
||||
featureDate >= new Date(queryModifiers.range[0]).getTime() &&
|
||||
featureDate <= new Date(queryModifiers.range[1]).getTime()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dropAllVariants(): Promise<void> {
|
||||
this.features.forEach((feature) => (feature.variants = []));
|
||||
return Promise.resolve();
|
||||
|
12
src/test/fixtures/fake-project-stats-store.ts
vendored
Normal file
12
src/test/fixtures/fake-project-stats-store.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
import { IProjectStats } from 'lib/services/project-service';
|
||||
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
export default class FakeProjectStatsStore implements IProjectStatsStore {
|
||||
updateProjectStats(
|
||||
projectId: string,
|
||||
status: IProjectStats,
|
||||
): Promise<void> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
}
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -32,6 +32,7 @@ import FakePublicSignupStore from './fake-public-signup-store';
|
||||
import FakeFavoriteFeaturesStore from './fake-favorite-features-store';
|
||||
import FakeFavoriteProjectsStore from './fake-favorite-projects-store';
|
||||
import { FakeAccountStore } from './fake-account-store';
|
||||
import FakeProjectStatsStore from './fake-project-stats-store';
|
||||
|
||||
const createStores: () => IUnleashStores = () => {
|
||||
const db = {
|
||||
@ -75,6 +76,7 @@ const createStores: () => IUnleashStores = () => {
|
||||
publicSignupTokenStore: new FakePublicSignupStore(),
|
||||
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
|
||||
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
|
||||
projectStatsStore: new FakeProjectStatsStore(),
|
||||
};
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user