diff --git a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts index 7c246dee04..81bf1bfa1f 100644 --- a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts +++ b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts @@ -3,11 +3,17 @@ import type { StageCount, StageCountByProject, } from './feature-lifecycle-read-model-type'; -import type { IFeatureLifecycleStage } from '../../types'; +import type { + IFeatureLifecycleStage, + IProjectLifecycleStageDuration, +} from '../../types'; export class FakeFeatureLifecycleReadModel implements IFeatureLifecycleReadModel { + getAllWithStageDuration(): Promise { + return Promise.resolve([]); + } getStageCount(): Promise { return Promise.resolve([]); } diff --git a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts index ff9a8b4685..70bfd51864 100644 --- a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts +++ b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts @@ -2,7 +2,6 @@ import type { FeatureLifecycleStage, IFeatureLifecycleStore, FeatureLifecycleView, - FeatureLifecycleProjectItem, } from './feature-lifecycle-store-type'; export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { @@ -41,18 +40,6 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { return this.lifecycles[feature] || []; } - async getAll(): Promise { - const result = Object.entries(this.lifecycles).flatMap( - ([key, items]): FeatureLifecycleProjectItem[] => - items.map((item) => ({ - ...item, - feature: key, - project: 'fake-project', - })), - ); - return result; - } - async delete(feature: string): Promise { this.lifecycles[feature] = []; } diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts index 139807e650..bfd636c27b 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts @@ -1,4 +1,8 @@ -import type { IFeatureLifecycleStage, StageName } from '../../types'; +import type { + IFeatureLifecycleStage, + IProjectLifecycleStageDuration, + StageName, +} from '../../types'; export type StageCount = { stage: StageName; @@ -15,4 +19,5 @@ export interface IFeatureLifecycleReadModel { ): Promise; getStageCount(): Promise; getStageCountByProject(): Promise; + getAllWithStageDuration(): Promise; } diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts index bd3df36177..caed22d8ed 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts @@ -8,8 +8,11 @@ import { getCurrentStage } from './get-current-stage'; import type { IFeatureLifecycleStage, IFlagResolver, + IProjectLifecycleStageDuration, StageName, } from '../../types'; +import { calculateStageDurations } from './calculate-stage-durations'; +import type { FeatureLifecycleProjectItem } from './feature-lifecycle-store-type'; type DBType = { feature: string; @@ -18,6 +21,10 @@ type DBType = { created_at: Date; }; +type DBProjectType = DBType & { + project: string; +}; + export class FeatureLifecycleReadModel implements IFeatureLifecycleReadModel { private db: Db; @@ -106,4 +113,31 @@ export class FeatureLifecycleReadModel implements IFeatureLifecycleReadModel { return getCurrentStage(stages); } + + private async getAll(): Promise { + const results = await this.db('feature_lifecycles as flc') + .select('flc.feature', 'flc.stage', 'flc.created_at', 'f.project') + .leftJoin('features as f', 'f.name', 'flc.feature') + .orderBy('created_at', 'asc'); + + return results.map( + ({ feature, stage, created_at, project }: DBProjectType) => ({ + feature, + stage, + project, + enteredStageAt: new Date(created_at), + }), + ); + } + + public async getAllWithStageDuration(): Promise< + IProjectLifecycleStageDuration[] + > { + if (!this.flagResolver.isEnabled('featureLifecycleMetrics')) { + return []; + } + + const featureLifeCycles = await this.getAll(); + return calculateStageDurations(featureLifeCycles); + } } diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts index dff3717c7d..08736c278a 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts @@ -10,7 +10,6 @@ import { type IEventStore, type IFeatureEnvironmentStore, type IFlagResolver, - type IProjectLifecycleStageDuration, type IUnleashConfig, } from '../../types'; import type { @@ -21,7 +20,6 @@ import EventEmitter from 'events'; import type { Logger } from '../../logger'; import type EventService from '../events/event-service'; import type { FeatureLifecycleCompletedSchema } from '../../openapi'; -import { calculateStageDurations } from './calculate-stage-durations'; import type { IClientMetricsEnv } from '../metrics/client-metrics/client-metrics-store-v2-type'; import groupBy from 'lodash.groupby'; @@ -231,11 +229,4 @@ export class FeatureLifecycleService extends EventEmitter { await this.featureLifecycleStore.delete(feature); await this.featureInitialized(feature); } - - public async getAllWithStageDuration(): Promise< - IProjectLifecycleStageDuration[] - > { - const featureLifeCycles = await this.featureLifecycleStore.getAll(); - return calculateStageDurations(featureLifeCycles); - } } diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts index 637e0c5855..2511a7155c 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts @@ -17,7 +17,6 @@ export type FeatureLifecycleProjectItem = FeatureLifecycleStage & { export interface IFeatureLifecycleStore { insert(featureLifecycleStages: FeatureLifecycleStage[]): Promise; get(feature: string): Promise; - getAll(): Promise; stageExists(stage: FeatureLifecycleStage): Promise; delete(feature: string): Promise; deleteAll(): Promise; diff --git a/src/lib/features/instance-stats/createInstanceStatsService.ts b/src/lib/features/instance-stats/createInstanceStatsService.ts index ddbf0302ba..0d133e781e 100644 --- a/src/lib/features/instance-stats/createInstanceStatsService.ts +++ b/src/lib/features/instance-stats/createInstanceStatsService.ts @@ -40,10 +40,6 @@ import FakeSettingStore from '../../../test/fixtures/fake-setting-store'; import FakeSegmentStore from '../../../test/fixtures/fake-segment-store'; import FakeStrategiesStore from '../../../test/fixtures/fake-strategies-store'; import FakeFeatureStrategiesStore from '../feature-toggle/fakes/fake-feature-strategies-store'; -import { - createFakeFeatureLifecycleService, - createFeatureLifecycleService, -} from '../feature-lifecycle/createFeatureLifecycle'; export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => { const { eventBus, getLogger, flagResolver } = config; @@ -88,10 +84,6 @@ export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => { getLogger, flagResolver, ); - const { featureLifecycleService } = createFeatureLifecycleService( - db, - config, - ); const instanceStatsServiceStores = { featureToggleStore, userStore, @@ -133,7 +125,6 @@ export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => { versionService, getActiveUsers, getProductionChanges, - featureLifecycleService, ); return instanceStatsService; @@ -156,8 +147,6 @@ export const createFakeInstanceStatsService = (config: IUnleashConfig) => { const apiTokenStore = new FakeApiTokenStore(); const clientMetricsStoreV2 = new FakeClientMetricsStoreV2(); - const { featureLifecycleService } = - createFakeFeatureLifecycleService(config); const instanceStatsServiceStores = { featureToggleStore, userStore, @@ -194,7 +183,6 @@ export const createFakeInstanceStatsService = (config: IUnleashConfig) => { versionService, getActiveUsers, getProductionChanges, - featureLifecycleService, ); return instanceStatsService; diff --git a/src/lib/features/instance-stats/instance-stats-service.test.ts b/src/lib/features/instance-stats/instance-stats-service.test.ts index 6b11afc68b..d3f423bec1 100644 --- a/src/lib/features/instance-stats/instance-stats-service.test.ts +++ b/src/lib/features/instance-stats/instance-stats-service.test.ts @@ -4,7 +4,6 @@ import createStores from '../../../test/fixtures/store'; import VersionService from '../../services/version-service'; import { createFakeGetActiveUsers } from './getActiveUsers'; import { createFakeGetProductionChanges } from './getProductionChanges'; -import { createFakeFeatureLifecycleService } from '../feature-lifecycle/createFeatureLifecycle'; let instanceStatsService: InstanceStatsService; let versionService: VersionService; @@ -24,7 +23,6 @@ beforeEach(() => { versionService, createFakeGetActiveUsers(), createFakeGetProductionChanges(), - createFakeFeatureLifecycleService(config).featureLifecycleService, ); jest.spyOn(instanceStatsService, 'refreshAppCountSnapshot'); diff --git a/src/lib/features/instance-stats/instance-stats-service.ts b/src/lib/features/instance-stats/instance-stats-service.ts index 23966fb480..3980eeb291 100644 --- a/src/lib/features/instance-stats/instance-stats-service.ts +++ b/src/lib/features/instance-stats/instance-stats-service.ts @@ -22,14 +22,12 @@ import { FEATURES_EXPORTED, FEATURES_IMPORTED, type IApiTokenStore, - type IProjectLifecycleStageDuration, type IFlagResolver, } from '../../types'; import { CUSTOM_ROOT_ROLE_TYPE } from '../../util'; import type { GetActiveUsers } from './getActiveUsers'; import type { ProjectModeCount } from '../project/project-store'; import type { GetProductionChanges } from './getProductionChanges'; -import type { FeatureLifecycleService } from '../feature-lifecycle/feature-lifecycle-service'; export type TimeRange = 'allTime' | '30d' | '7d'; @@ -63,7 +61,6 @@ export interface InstanceStats { enabledCount: number; variantCount: number; }; - featureLifeCycles: IProjectLifecycleStageDuration[]; } export type InstanceStatsSigned = Omit & { @@ -94,8 +91,6 @@ export class InstanceStatsService { private eventStore: IEventStore; - private featureLifecycleService: FeatureLifecycleService; - private apiTokenStore: IApiTokenStore; private versionService: VersionService; @@ -154,7 +149,6 @@ export class InstanceStatsService { versionService: VersionService, getActiveUsers: GetActiveUsers, getProductionChanges: GetProductionChanges, - featureLifecycleService: FeatureLifecycleService, ) { this.strategyStore = strategyStore; this.userStore = userStore; @@ -169,7 +163,6 @@ export class InstanceStatsService { this.settingStore = settingStore; this.eventStore = eventStore; this.clientInstanceStore = clientInstanceStore; - this.featureLifecycleService = featureLifecycleService; this.logger = getLogger('services/stats-service.js'); this.getActiveUsers = getActiveUsers; this.getProductionChanges = getProductionChanges; @@ -257,7 +250,6 @@ export class InstanceStatsService { featureImports, productionChanges, previousDayMetricsBucketsCount, - featureLifeCycles, ] = await Promise.all([ this.getToggleCount(), this.getArchivedToggleCount(), @@ -281,7 +273,6 @@ export class InstanceStatsService { this.eventStore.filteredCount({ type: FEATURES_IMPORTED }), this.getProductionChanges(), this.clientMetricsStore.countPreviousDayHourlyMetricsBuckets(), - this.getAllWithStageDuration(), ]); return { @@ -314,7 +305,6 @@ export class InstanceStatsService { featureImports, productionChanges, previousDayMetricsBucketsCount, - featureLifeCycles, }; } @@ -348,11 +338,4 @@ export class InstanceStatsService { ); return { ...instanceStats, sum, projects: totalProjects }; } - - async getAllWithStageDuration(): Promise { - if (this.flagResolver.isEnabled('featureLifecycleMetrics')) { - return this.featureLifecycleService.getAllWithStageDuration(); - } - return []; - } } diff --git a/src/lib/metrics.test.ts b/src/lib/metrics.test.ts index b8cb5b0a63..d897a3adef 100644 --- a/src/lib/metrics.test.ts +++ b/src/lib/metrics.test.ts @@ -16,14 +16,19 @@ import { InstanceStatsService } from './features/instance-stats/instance-stats-s import VersionService from './services/version-service'; import { createFakeGetActiveUsers } from './features/instance-stats/getActiveUsers'; import { createFakeGetProductionChanges } from './features/instance-stats/getProductionChanges'; -import type { IEnvironmentStore, IUnleashStores } from './types'; +import type { + IEnvironmentStore, + IFeatureLifecycleReadModel, + IFeatureLifecycleStore, + IUnleashStores, +} from './types'; import FakeEnvironmentStore from './features/project-environments/fake-environment-store'; import { SchedulerService } from './services'; import noLogger from '../test/fixtures/no-logger'; -import { createFeatureLifecycleService } from './features'; -import dbInit, { type ITestDb } from '../test/e2e/helpers/database-init'; import getLogger from '../test/fixtures/no-logger'; -import type { FeatureLifecycleStore } from './features/feature-lifecycle/feature-lifecycle-store'; +import dbInit, { type ITestDb } from '../test/e2e/helpers/database-init'; +import { FeatureLifecycleStore } from './features/feature-lifecycle/feature-lifecycle-store'; +import { FeatureLifecycleReadModel } from './features/feature-lifecycle/feature-lifecycle-read-model'; const monitor = createMetricsMonitor(); const eventBus = new EventEmitter(); @@ -33,7 +38,8 @@ let environmentStore: IEnvironmentStore; let statsService: InstanceStatsService; let stores: IUnleashStores; let schedulerService: SchedulerService; -let featureLifeCycleStore: FeatureLifecycleStore; +let featureLifeCycleStore: IFeatureLifecycleStore; +let featureLifeCycleReadModel: IFeatureLifecycleReadModel; let db: ITestDb; beforeAll(async () => { @@ -59,8 +65,13 @@ beforeAll(async () => { ); db = await dbInit('metrics_test', getLogger); - const { featureLifecycleService, featureLifecycleStore } = - createFeatureLifecycleService(db.rawDatabase, config); + featureLifeCycleReadModel = new FeatureLifecycleReadModel( + db.rawDatabase, + config.flagResolver, + ); + stores.featureLifecycleReadModel = featureLifeCycleReadModel; + featureLifeCycleStore = new FeatureLifecycleStore(db.rawDatabase); + stores.featureLifecycleStore = featureLifeCycleStore; statsService = new InstanceStatsService( stores, @@ -68,9 +79,7 @@ beforeAll(async () => { versionService, createFakeGetActiveUsers(), createFakeGetProductionChanges(), - featureLifecycleService, ); - featureLifeCycleStore = featureLifecycleStore; schedulerService = new SchedulerService( noLogger, @@ -303,9 +312,13 @@ test('should collect metrics for lifecycle', async () => { stage: 'initial', }, ]); - const { featureLifeCycles } = await statsService.getStats(); - expect(featureLifeCycles).toHaveLength(1); + const stageCount = await featureLifeCycleReadModel.getStageCountByProject(); + const stageDurations = + await featureLifeCycleReadModel.getAllWithStageDuration(); + expect(stageCount).toHaveLength(1); + expect(stageDurations).toHaveLength(1); const metrics = await prometheusRegister.metrics(); expect(metrics).toMatch(/feature_lifecycle_stage_duration/); + expect(metrics).toMatch(/stage_count_by_project/); }); diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 0a7da71e4c..08d71debc3 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -306,12 +306,14 @@ export default class MetricsMonitor { maxConstraintValuesResult, maxConstraintsPerStrategyResult, stageCountByProjectResult, + stageDurationByProject, ] = await Promise.all([ stores.featureStrategiesReadModel.getMaxFeatureStrategies(), stores.featureStrategiesReadModel.getMaxFeatureEnvironmentStrategies(), stores.featureStrategiesReadModel.getMaxConstraintValues(), stores.featureStrategiesReadModel.getMaxConstraintsPerStrategy(), stores.featureLifecycleReadModel.getStageCountByProject(), + stores.featureLifecycleReadModel.getAllWithStageDuration(), ]); featureFlagsTotal.reset(); @@ -326,7 +328,7 @@ export default class MetricsMonitor { serviceAccounts.reset(); serviceAccounts.set(stats.serviceAccounts); - stats.featureLifeCycles.forEach((stage) => { + stageDurationByProject.forEach((stage) => { featureLifecycleStageDuration .labels({ stage: stage.stage, diff --git a/src/lib/routes/admin-api/instance-admin.ts b/src/lib/routes/admin-api/instance-admin.ts index 02d532667a..65e1b457b7 100644 --- a/src/lib/routes/admin-api/instance-admin.ts +++ b/src/lib/routes/admin-api/instance-admin.ts @@ -125,13 +125,6 @@ class InstanceAdminController extends Controller { variantCount: 100, enabledCount: 200, }, - featureLifeCycles: [ - { - project: 'default', - stage: 'archived', - duration: 2000, - }, - ], }; }