From f4d857285bfebe2627d67b33c5ae22acb876d007 Mon Sep 17 00:00:00 2001 From: Fredrik Strand Oseberg Date: Thu, 19 Jan 2023 13:27:50 +0100 Subject: [PATCH] feat: status API (#2931) Initial status API --- .../__snapshots__/create-config.test.ts.snap | 2 + src/lib/db/environment-store.ts | 7 + src/lib/db/event-store.ts | 19 ++ .../project-status/project-status.test.ts | 208 ++++++++++++++++++ .../project-status/project-status.ts | 111 ++++++++++ src/lib/services/index.ts | 8 + src/lib/services/project-service.ts | 44 ++++ src/lib/types/experimental.ts | 4 + src/lib/types/stores/environment-store.ts | 5 +- src/lib/types/stores/event-store.ts | 5 + src/lib/types/stores/feature-toggle-store.ts | 1 + src/test/e2e/helpers/database-init.ts | 4 +- .../e2e/services/project-service.e2e.test.ts | 59 +++++ src/test/fixtures/fake-event-store.ts | 15 ++ 14 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 src/lib/read-models/project-status/project-status.test.ts create mode 100644 src/lib/read-models/project-status/project-status.ts diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 82783012cb..8df8f770c9 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -79,6 +79,7 @@ exports[`should create default config 1`] = ` "messageBanner": false, "networkView": false, "newProjectOverview": false, + "projectStatusApi": false, "proxyReturnAllToggles": false, "responseTimeWithAppName": false, "serviceAccounts": false, @@ -99,6 +100,7 @@ exports[`should create default config 1`] = ` "messageBanner": false, "networkView": false, "newProjectOverview": false, + "projectStatusApi": false, "proxyReturnAllToggles": false, "responseTimeWithAppName": false, "serviceAccounts": false, diff --git a/src/lib/db/environment-store.ts b/src/lib/db/environment-store.ts index c7bde31ae9..9b863e0d91 100644 --- a/src/lib/db/environment-store.ts +++ b/src/lib/db/environment-store.ts @@ -183,6 +183,7 @@ export default class EnvironmentStore implements IEnvironmentStore { async getProjectEnvironments( projectId: string, + query?: Object, ): Promise { let qB = this.db(TABLE) .select( @@ -200,7 +201,13 @@ export default class EnvironmentStore implements IEnvironmentStore { { column: 'sort_order', order: 'asc' }, { column: 'created_at', order: 'asc' }, ]); + + if (query) { + qB = qB.where(query); + } + const rows = await qB; + return rows.map(mapRowWithProjectCounts); } diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts index 33e0650cf3..0f041ba86d 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -120,6 +120,25 @@ class EventStore extends AnyEventEmitter implements IEventStore { return present; } + async getForFeatures( + features: string[], + environments: string[], + query: { type: string; projectId: string }, + ): Promise { + 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 { const row = await this.db(TABLE).where({ id: key }).first(); return this.rowToEvent(row); diff --git a/src/lib/read-models/project-status/project-status.test.ts b/src/lib/read-models/project-status/project-status.test.ts new file mode 100644 index 0000000000..25f5485b52 --- /dev/null +++ b/src/lib/read-models/project-status/project-status.test.ts @@ -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) => { + 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); + }); +}); diff --git a/src/lib/read-models/project-status/project-status.ts b/src/lib/read-models/project-status/project-status.ts new file mode 100644 index 0000000000..9a3fbb21cb --- /dev/null +++ b/src/lib/read-models/project-status/project-status.ts @@ -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; + }, {}); + } +} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 5eed27e691..a3d72efc65 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -161,6 +161,14 @@ export const createServices = ( minutesToMilliseconds(5), ); + if (config.flagResolver.isEnabled('projectStatusApi')) { + const ONE_DAY = 1440; + schedulerService.schedule( + projectService.statusJob.bind(projectHealthService), + minutesToMilliseconds(ONE_DAY), + ); + } + return { accessService, accountService, diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index de1ffba846..a0e7da9c31 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -15,6 +15,7 @@ import { ProjectGroupAddedEvent, ProjectGroupRemovedEvent, ProjectGroupUpdateRoleEvent, + FEATURE_ENVIRONMENT_ENABLED, } from '../types/events'; import { IUnleashStores, IUnleashConfig, IAccountStore } from '../types'; import { @@ -46,6 +47,7 @@ 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'; const getCreatedBy = (user: IUser) => user.email || user.username; @@ -591,6 +593,48 @@ export default class ProjectService { return this.store.getProjectsByUser(userId); } + async statusJob(): Promise { + const projects = await this.store.getAll(); + + await Promise.all( + projects.map((project) => + this.calculateAverageTimeToProd(project.id), + ), + ); + } + + async calculateAverageTimeToProd(projectId: string): Promise { + // 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( projectId: string, archived: boolean = false, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index b058d79e8b..aa4bbb88cc 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -10,6 +10,10 @@ const flags = { process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY, true, ), + projectStatusApi: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API, + false, + ), newProjectOverview: parseEnvVarBoolean( process.env.NEW_PROJECT_OVERVIEW, false, diff --git a/src/lib/types/stores/environment-store.ts b/src/lib/types/stores/environment-store.ts index 1e5694ed42..fdd6862459 100644 --- a/src/lib/types/stores/environment-store.ts +++ b/src/lib/types/stores/environment-store.ts @@ -24,5 +24,8 @@ export interface IEnvironmentStore extends Store { enable(environments: IEnvironment[]): Promise; count(): Promise; getAllWithCounts(): Promise; - getProjectEnvironments(projectId: string): Promise; + getProjectEnvironments( + projectId: string, + query?: Object, + ): Promise; } diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 516783b7e0..19be70501d 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -10,4 +10,9 @@ export interface IEventStore extends Store, EventEmitter { count(): Promise; filteredCount(search: SearchEventsSchema): Promise; searchEvents(search: SearchEventsSchema): Promise; + getForFeatures( + features: string[], + environments: string[], + query: { type: string; projectId: string }, + ): Promise; } diff --git a/src/lib/types/stores/feature-toggle-store.ts b/src/lib/types/stores/feature-toggle-store.ts index 5419395aa2..6c8ecc1808 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -5,6 +5,7 @@ export interface IFeatureToggleQuery { archived: boolean; project: string; stale: boolean; + type?: string; } export interface IFeatureToggleStore extends Store { diff --git a/src/test/e2e/helpers/database-init.ts b/src/test/e2e/helpers/database-init.ts index 622404ed17..da06c6220f 100644 --- a/src/test/e2e/helpers/database-init.ts +++ b/src/test/e2e/helpers/database-init.ts @@ -10,7 +10,7 @@ import EnvironmentStore from '../../../lib/db/environment-store'; import { IUnleashStores } from '../../../lib/types'; import { IFeatureEnvironmentStore } from '../../../lib/types/stores/feature-environment-store'; 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); @@ -76,6 +76,7 @@ export interface ITestDb { stores: IUnleashStores; reset: () => Promise; destroy: () => Promise; + rawDatabase: Knex; } export default async function init( @@ -108,6 +109,7 @@ export default async function init( await setupDatabase(stores); return { + rawDatabase: testDb, stores, reset: async () => { await resetDatabase(testDb); diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index 9d272447e1..19bf80bfb0 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -12,6 +12,8 @@ import IncompatibleProjectError from '../../../lib/error/incompatible-project-er 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'; let stores; let db: ITestDb; @@ -72,6 +74,7 @@ afterEach(async () => { const wipeUserPermissions = users.map(async (u) => { await stores.accessStore.unlinkUserRoles(u.id); }); + await stores.eventStore.deleteAll(); await Promise.allSettled(deleteEnvs); 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); 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); +}); diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index 1feb298dc4..5c75ecfcdf 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -65,6 +65,21 @@ class FakeEventStore extends AnyEventEmitter implements IEventStore { async searchEvents(): Promise { throw new Error('Method not implemented.'); } + + async getForFeatures( + features: string[], + environments: string[], + query: { type: string; projectId: string }, + ): Promise { + 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;