1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-27 13:49:10 +02:00

Project stats 4.22.4 (#3504)

This commit is contained in:
Mateusz Kwasniewski 2023-04-12 11:19:53 +02:00 committed by GitHub
parent ed47e5663e
commit 5208038fa4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 224 additions and 409 deletions

View File

@ -89,6 +89,7 @@ exports[`should create default config 1`] = `
"projectScopedSegments": false,
"projectScopedStickiness": false,
"projectStatusApi": false,
"projectStatusApiImprovements": false,
"responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false,
},
@ -117,6 +118,7 @@ exports[`should create default config 1`] = `
"projectScopedSegments": false,
"projectScopedStickiness": false,
"projectStatusApi": false,
"projectStatusApiImprovements": false,
"responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false,
},

View File

@ -110,16 +110,16 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return rows.map(this.rowToFeature);
}
async getByDate(queryModifiers: {
async countByDate(queryModifiers: {
archived?: boolean;
project?: string;
date?: string;
range?: string[];
dateAccessor: string;
}): Promise<FeatureToggle[]> {
}): Promise<number> {
const { project, archived, dateAccessor } = queryModifiers;
let query = this.db
.select(FEATURE_COLUMNS)
.count()
.from(TABLE)
.where({ project })
.modify(FeatureToggleStore.filterByArchived, archived);
@ -135,8 +135,8 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
]);
}
const rows = await query;
return rows.map(this.rowToFeature);
const queryResult = await query.first();
return parseInt(queryResult.count || 0);
}
/**

View File

@ -4,7 +4,10 @@ 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';
import {
ICreateEnabledDates,
IProjectStatsStore,
} from 'lib/types/stores/project-stats-store-type';
import { Db } from './db';
const TABLE = 'project_stats';
@ -107,6 +110,40 @@ class ProjectStatsStore implements IProjectStatsStore {
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;

View File

@ -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);
});
});

View File

@ -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;
};

View File

@ -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);
});
});

View File

@ -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;
}, {});
}
}

View File

@ -8,7 +8,6 @@ import { projectSchema } from './project-schema';
import NotFoundError from '../error/notfound-error';
import {
DEFAULT_PROJECT,
FEATURE_ENVIRONMENT_ENABLED,
FeatureToggle,
IAccountStore,
IEnvironmentStore,
@ -54,7 +53,7 @@ import { arraysHaveSameItems } from '../util';
import { GroupService } from './group-service';
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
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 { uniqueByKey } from '../util/unique';
@ -688,42 +687,32 @@ export default class ProjectService {
}
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 dateMinusSixtyDays = subDays(new Date(), 60).toISOString();
const [createdCurrentWindow, createdPastWindow] = await Promise.all([
await this.featureToggleStore.getByDate({
const [
createdCurrentWindow,
createdPastWindow,
archivedCurrentWindow,
archivedPastWindow,
] = await Promise.all([
await this.featureToggleStore.countByDate({
project: projectId,
dateAccessor: 'created_at',
date: dateMinusThirtyDays,
}),
await this.featureToggleStore.getByDate({
await this.featureToggleStore.countByDate({
project: projectId,
dateAccessor: 'created_at',
range: [dateMinusSixtyDays, dateMinusThirtyDays],
}),
]);
const [archivedCurrentWindow, archivedPastWindow] = await Promise.all([
await this.featureToggleStore.getByDate({
await this.featureToggleStore.countByDate({
project: projectId,
archived: true,
dateAccessor: 'archived_at',
date: dateMinusThirtyDays,
}),
await this.featureToggleStore.getByDate({
await this.featureToggleStore.countByDate({
project: projectId,
archived: true,
dateAccessor: 'archived_at',
@ -755,33 +744,8 @@ export default class ProjectService {
]),
]);
// Get all project environments with type of production
const productionEnvironments =
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 avgTimeToProdCurrentWindow = calculateAverageTimeToProd(
await this.projectStatsStore.getTimeToProdDates(projectId),
);
const projectMembersAddedCurrentWindow =
@ -793,15 +757,13 @@ export default class ProjectService {
return {
projectId,
updates: {
avgTimeToProdCurrentWindow:
currentWindowTimeToProdReadModel.calculateAverageTimeToProd(),
createdCurrentWindow: createdCurrentWindow.length,
createdPastWindow: createdPastWindow.length,
archivedCurrentWindow: archivedCurrentWindow.length,
archivedPastWindow: archivedPastWindow.length,
projectActivityCurrentWindow: projectActivityCurrentWindow,
projectActivityPastWindow: projectActivityPastWindow,
projectMembersAddedCurrentWindow:
avgTimeToProdCurrentWindow,
createdCurrentWindow,
createdPastWindow,
archivedCurrentWindow,
archivedPastWindow,
projectActivityCurrentWindow,
projectActivityPastWindow,
projectMembersAddedCurrentWindow,
},
};

View File

@ -14,6 +14,10 @@ const flags = {
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API,
false,
),
projectStatusApiImprovements: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API_IMPROVEMENTS,
false,
),
newProjectOverview: parseEnvVarBoolean(
process.env.NEW_PROJECT_OVERVIEW,
false,

View File

@ -25,13 +25,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: {
countByDate(queryModifiers: {
archived?: boolean;
project?: string;
date?: string;
range?: string[];
dateAccessor: string;
}): Promise<FeatureToggle[]>;
}): Promise<number>;
/**
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
* @param featureName

View File

@ -1,6 +1,12 @@
import { IProjectStats } from 'lib/services/project-service';
export interface ICreateEnabledDates {
created: Date;
enabled: Date;
}
export interface IProjectStatsStore {
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
getProjectStats(projectId: string): Promise<IProjectStats>;
getTimeToProdDates(projectId: string): Promise<ICreateEnabledDates[]>;
}

View File

@ -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 { 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';
let stores;
@ -1310,6 +1310,94 @@ test('should calculate average time to production', async () => {
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 () => {
const project = {
id: 'features-created',

View File

@ -213,13 +213,13 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return Promise.resolve(newVariants);
}
async getByDate(queryModifiers: {
async countByDate(queryModifiers: {
archived?: boolean;
project?: string;
date?: string;
range?: string[];
dateAccessor: string;
}): Promise<FeatureToggle[]> {
}): Promise<number> {
return this.features.filter((feature) => {
if (feature.archived === queryModifiers.archived) {
return true;
@ -245,7 +245,7 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
) {
return true;
}
});
}).length;
}
dropAllVariants(): Promise<void> {

View File

@ -1,5 +1,8 @@
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 */
export default class FakeProjectStatsStore implements IProjectStatsStore {
@ -13,4 +16,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore {
getProjectStats(projectId: string): Promise<IProjectStats> {
throw new Error('not implemented');
}
getTimeToProdDates(): Promise<ICreateEnabledDates[]> {
throw new Error('not implemented');
}
}