1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-14 00:19:16 +01:00

feat: status API (#2931)

Initial status API
This commit is contained in:
Fredrik Strand Oseberg 2023-01-19 13:27:50 +01:00 committed by GitHub
parent 467eb57fb8
commit f4d857285b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 490 additions and 2 deletions

View File

@ -79,6 +79,7 @@ exports[`should create default config 1`] = `
"messageBanner": false, "messageBanner": false,
"networkView": false, "networkView": false,
"newProjectOverview": false, "newProjectOverview": false,
"projectStatusApi": false,
"proxyReturnAllToggles": false, "proxyReturnAllToggles": false,
"responseTimeWithAppName": false, "responseTimeWithAppName": false,
"serviceAccounts": false, "serviceAccounts": false,
@ -99,6 +100,7 @@ exports[`should create default config 1`] = `
"messageBanner": false, "messageBanner": false,
"networkView": false, "networkView": false,
"newProjectOverview": false, "newProjectOverview": false,
"projectStatusApi": false,
"proxyReturnAllToggles": false, "proxyReturnAllToggles": false,
"responseTimeWithAppName": false, "responseTimeWithAppName": false,
"serviceAccounts": false, "serviceAccounts": false,

View File

@ -183,6 +183,7 @@ export default class EnvironmentStore implements IEnvironmentStore {
async getProjectEnvironments( async getProjectEnvironments(
projectId: string, projectId: string,
query?: Object,
): Promise<IProjectEnvironment[]> { ): Promise<IProjectEnvironment[]> {
let qB = this.db<IEnvironmentsWithProjectCountsTable>(TABLE) let qB = this.db<IEnvironmentsWithProjectCountsTable>(TABLE)
.select( .select(
@ -200,7 +201,13 @@ export default class EnvironmentStore implements IEnvironmentStore {
{ column: 'sort_order', order: 'asc' }, { column: 'sort_order', order: 'asc' },
{ column: 'created_at', order: 'asc' }, { column: 'created_at', order: 'asc' },
]); ]);
if (query) {
qB = qB.where(query);
}
const rows = await qB; const rows = await qB;
return rows.map(mapRowWithProjectCounts); return rows.map(mapRowWithProjectCounts);
} }

View File

@ -120,6 +120,25 @@ class EventStore extends AnyEventEmitter implements IEventStore {
return present; return present;
} }
async getForFeatures(
features: string[],
environments: string[],
query: { type: string; projectId: string },
): 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);
return rows.map(this.rowToEvent);
} catch (e) {
return [];
}
}
async get(key: number): Promise<IEvent> { async get(key: number): Promise<IEvent> {
const row = await this.db(TABLE).where({ id: key }).first(); const row = await this.db(TABLE).where({ id: key }).first();
return this.rowToEvent(row); return this.rowToEvent(row);

View File

@ -0,0 +1,208 @@
import { addDays, subDays } from 'date-fns';
import { IEvent } from 'lib/types';
import { ProjectStatus } from './project-status';
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('2023-01-19T09: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 ProjectStatus(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 ProjectStatus(features, environments, events);
const timeToProduction = projectStatus.calculateAverageTimeToProd();
expect(timeToProduction).toBe(9.75);
});
test('should sort events by createdAt', () => {
const projectStatus = new ProjectStatus(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 ProjectStatus(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(9.75);
});
});

View File

@ -0,0 +1,111 @@
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 ProjectStatus {
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);
const sum = timeToProdPerFeature.reduce((acc, curr) => acc + curr, 0);
return sum / Object.keys(sortedFeatureEvents).length;
}
getFeatureEvents(): IFeatureTimeToProdCalculationMap {
return this.filterEvents(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;
}, {});
}
filterEvents(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

@ -161,6 +161,14 @@ export const createServices = (
minutesToMilliseconds(5), minutesToMilliseconds(5),
); );
if (config.flagResolver.isEnabled('projectStatusApi')) {
const ONE_DAY = 1440;
schedulerService.schedule(
projectService.statusJob.bind(projectHealthService),
minutesToMilliseconds(ONE_DAY),
);
}
return { return {
accessService, accessService,
accountService, accountService,

View File

@ -15,6 +15,7 @@ import {
ProjectGroupAddedEvent, ProjectGroupAddedEvent,
ProjectGroupRemovedEvent, ProjectGroupRemovedEvent,
ProjectGroupUpdateRoleEvent, ProjectGroupUpdateRoleEvent,
FEATURE_ENVIRONMENT_ENABLED,
} from '../types/events'; } from '../types/events';
import { IUnleashStores, IUnleashConfig, IAccountStore } from '../types'; import { IUnleashStores, IUnleashConfig, IAccountStore } from '../types';
import { import {
@ -46,6 +47,7 @@ import { arraysHaveSameItems } from '../util/arraysHaveSameItems';
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 { ProjectStatus } from '../read-models/project-status/project-status';
const getCreatedBy = (user: IUser) => user.email || user.username; const getCreatedBy = (user: IUser) => user.email || user.username;
@ -591,6 +593,48 @@ export default class ProjectService {
return this.store.getProjectsByUser(userId); return this.store.getProjectsByUser(userId);
} }
async statusJob(): Promise<void> {
const projects = await this.store.getAll();
await Promise.all(
projects.map((project) =>
this.calculateAverageTimeToProd(project.id),
),
);
}
async calculateAverageTimeToProd(projectId: string): Promise<number> {
// Get all features for project with type release
const features = await this.featureToggleStore.getAll({
type: 'release',
project: projectId,
});
// 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 events = await this.eventStore.getForFeatures(
features.map((feature) => feature.name),
productionEnvironments.map((env) => env.name),
{
type: FEATURE_ENVIRONMENT_ENABLED,
projectId,
},
);
const projectStatus = new ProjectStatus(
features,
productionEnvironments,
events,
);
return projectStatus.calculateAverageTimeToProd();
}
async getProjectOverview( async getProjectOverview(
projectId: string, projectId: string,
archived: boolean = false, archived: boolean = false,

View File

@ -10,6 +10,10 @@ const flags = {
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY, process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
true, true,
), ),
projectStatusApi: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API,
false,
),
newProjectOverview: parseEnvVarBoolean( newProjectOverview: parseEnvVarBoolean(
process.env.NEW_PROJECT_OVERVIEW, process.env.NEW_PROJECT_OVERVIEW,
false, false,

View File

@ -24,5 +24,8 @@ export interface IEnvironmentStore extends Store<IEnvironment, string> {
enable(environments: IEnvironment[]): Promise<void>; enable(environments: IEnvironment[]): Promise<void>;
count(): Promise<number>; count(): Promise<number>;
getAllWithCounts(): Promise<IEnvironment[]>; getAllWithCounts(): Promise<IEnvironment[]>;
getProjectEnvironments(projectId: string): Promise<IProjectEnvironment[]>; getProjectEnvironments(
projectId: string,
query?: Object,
): Promise<IProjectEnvironment[]>;
} }

View File

@ -10,4 +10,9 @@ export interface IEventStore extends Store<IEvent, number>, EventEmitter {
count(): Promise<number>; count(): Promise<number>;
filteredCount(search: SearchEventsSchema): Promise<number>; filteredCount(search: SearchEventsSchema): Promise<number>;
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>; searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
getForFeatures(
features: string[],
environments: string[],
query: { type: string; projectId: string },
): Promise<IEvent[]>;
} }

View File

@ -5,6 +5,7 @@ export interface IFeatureToggleQuery {
archived: boolean; archived: boolean;
project: string; project: string;
stale: boolean; stale: boolean;
type?: string;
} }
export interface IFeatureToggleStore extends Store<FeatureToggle, string> { export interface IFeatureToggleStore extends Store<FeatureToggle, string> {

View File

@ -10,7 +10,7 @@ import EnvironmentStore from '../../../lib/db/environment-store';
import { IUnleashStores } from '../../../lib/types'; import { IUnleashStores } from '../../../lib/types';
import { IFeatureEnvironmentStore } from '../../../lib/types/stores/feature-environment-store'; import { IFeatureEnvironmentStore } from '../../../lib/types/stores/feature-environment-store';
import { DEFAULT_ENV } from '../../../lib/util/constants'; import { DEFAULT_ENV } from '../../../lib/util/constants';
import { IUnleashOptions } from 'lib/server-impl'; import { IUnleashOptions, Knex } from 'lib/server-impl';
// require('db-migrate-shared').log.silence(false); // require('db-migrate-shared').log.silence(false);
@ -76,6 +76,7 @@ export interface ITestDb {
stores: IUnleashStores; stores: IUnleashStores;
reset: () => Promise<void>; reset: () => Promise<void>;
destroy: () => Promise<void>; destroy: () => Promise<void>;
rawDatabase: Knex;
} }
export default async function init( export default async function init(
@ -108,6 +109,7 @@ export default async function init(
await setupDatabase(stores); await setupDatabase(stores);
return { return {
rawDatabase: testDb,
stores, stores,
reset: async () => { reset: async () => {
await resetDatabase(testDb); await resetDatabase(testDb);

View File

@ -12,6 +12,8 @@ import IncompatibleProjectError from '../../../lib/error/incompatible-project-er
import { SegmentService } from '../../../lib/services/segment-service'; 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 { addDays } from 'date-fns';
let stores; let stores;
let db: ITestDb; let db: ITestDb;
@ -72,6 +74,7 @@ afterEach(async () => {
const wipeUserPermissions = users.map(async (u) => { const wipeUserPermissions = users.map(async (u) => {
await stores.accessStore.unlinkUserRoles(u.id); await stores.accessStore.unlinkUserRoles(u.id);
}); });
await stores.eventStore.deleteAll();
await Promise.allSettled(deleteEnvs); await Promise.allSettled(deleteEnvs);
await Promise.allSettled(wipeUserPermissions); await Promise.allSettled(wipeUserPermissions);
}); });
@ -1053,3 +1056,59 @@ test('should only count active feature toggles for project', async () => {
const theProject = projects.find((p) => p.id === project.id); const theProject = projects.find((p) => p.id === project.id);
expect(theProject?.featureCount).toBe(1); expect(theProject?.featureCount).toBe(1);
}); });
const updateEventCreatedAt = async (days: number, featureName: string) => {
await db.rawDatabase
.table('events')
.update({ created_at: addDays(new Date(), days) })
.where({ feature_name: featureName });
};
test('should calculate average time to production', async () => {
const project = {
id: 'average-time-to-prod',
name: 'average-time-to-prod',
};
await projectService.createProject(project, user.id);
const toggles = [
{ name: 'average-prod-time' },
{ name: 'average-prod-time-2' },
{ name: 'average-prod-time-3' },
{ name: 'average-prod-time-4' },
];
const featureToggles = await Promise.all(
toggles.map((toggle) => {
return featureToggleService.createFeatureToggle(
project.id,
toggle,
user,
);
}),
);
await Promise.all(
featureToggles.map((toggle) => {
return stores.eventStore.store(
new FeatureEnvironmentEvent({
enabled: true,
project: project.id,
featureName: toggle.name,
environment: 'default',
createdBy: 'Fredrik',
tags: [],
}),
);
}),
);
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');
const result = await projectService.calculateAverageTimeToProd(project.id);
expect(result).toBe(9.75);
});

View File

@ -65,6 +65,21 @@ class FakeEventStore extends AnyEventEmitter implements IEventStore {
async searchEvents(): Promise<IEvent[]> { async searchEvents(): Promise<IEvent[]> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
async getForFeatures(
features: string[],
environments: string[],
query: { type: string; projectId: string },
): Promise<IEvent[]> {
return this.events.filter((event) => {
return (
event.type === query.type &&
event.project === query.projectId &&
features.includes(event.data.featureName) &&
environments.includes(event.data.environment)
);
});
}
} }
module.exports = FakeEventStore; module.exports = FakeEventStore;