From efda70ac5de57e6fcebebcbd1f203fd37d2cd93a Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Fri, 5 Apr 2024 13:42:03 +0200 Subject: [PATCH] feat: feature lifecycle usage behind a flag (#6786) --- frontend/src/interfaces/uiConfig.ts | 1 + .../__snapshots__/create-config.test.ts.snap | 1 + .../createFeatureLifecycle.ts | 16 +++-- .../feature-lifecycle-service.test.ts | 34 +++++++++- .../feature-lifecycle-service.ts | 66 +++++++++++++------ src/lib/features/index.ts | 1 + src/lib/services/index.ts | 7 ++ src/lib/types/experimental.ts | 7 +- src/lib/types/services.ts | 2 + src/server-dev.ts | 1 + 10 files changed, 108 insertions(+), 28 deletions(-) diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 5d215b95fc..188980fd4e 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -80,6 +80,7 @@ export type UiFlags = { disableShowContextFieldSelectionValues?: boolean; variantDependencies?: boolean; projectOverviewRefactorFeedback?: boolean; + featureLifecycle?: boolean; }; export interface IVersionInfo { diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index ce03a4b6fb..fc892f4f8f 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -98,6 +98,7 @@ exports[`should create default config 1`] = ` "executiveDashboard": false, "executiveDashboardUI": false, "extendedUsageMetrics": false, + "featureLifecycle": false, "featureSearchFeedback": { "enabled": false, "name": "withText", diff --git a/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts b/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts index 8f9d171eab..7b28ee5e46 100644 --- a/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts +++ b/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts @@ -2,16 +2,20 @@ import FakeEventStore from '../../../test/fixtures/fake-event-store'; import { FakeFeatureLifecycleStore } from './fake-feature-lifecycle-store'; import { FeatureLifecycleService } from './feature-lifecycle-service'; import FakeEnvironmentStore from '../project-environments/fake-environment-store'; +import type { IUnleashConfig } from '../../types'; -export const createFakeFeatureLifecycleService = () => { +export const createFakeFeatureLifecycleService = (config: IUnleashConfig) => { const eventStore = new FakeEventStore(); const featureLifecycleStore = new FakeFeatureLifecycleStore(); const environmentStore = new FakeEnvironmentStore(); - const featureLifecycleService = new FeatureLifecycleService({ - eventStore, - featureLifecycleStore, - environmentStore, - }); + const featureLifecycleService = new FeatureLifecycleService( + { + eventStore, + featureLifecycleStore, + environmentStore, + }, + config, + ); return { featureLifecycleService, diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts index c52e06afd3..7ee3154b44 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts @@ -4,6 +4,7 @@ import { FEATURE_COMPLETED, FEATURE_CREATED, type IEnvironment, + type IUnleashConfig, } from '../../types'; import { createFakeFeatureLifecycleService } from './createFeatureLifecycle'; @@ -13,12 +14,16 @@ function ms(timeMs) { test('can insert and read lifecycle stages', async () => { const { featureLifecycleService, eventStore, environmentStore } = - createFakeFeatureLifecycleService(); + createFakeFeatureLifecycleService({ + flagResolver: { isEnabled: () => true }, + } as unknown as IUnleashConfig); const featureName = 'testFeature'; + async function emitMetricsEvent(environment: string) { await eventStore.emit(CLIENT_METRICS, { featureName, environment }); await ms(1); } + await environmentStore.create({ name: 'my-dev-environment', type: 'development', @@ -61,3 +66,30 @@ test('can insert and read lifecycle stages', async () => { { stage: 'archived', enteredStageAt: expect.any(Date) }, ]); }); + +test('ignores lifecycle state updates when flag disabled', async () => { + const { featureLifecycleService, eventStore, environmentStore } = + createFakeFeatureLifecycleService({ + flagResolver: { isEnabled: () => false }, + } as unknown as IUnleashConfig); + const featureName = 'testFeature'; + + await environmentStore.create({ + name: 'my-dev-environment', + type: 'development', + } as IEnvironment); + featureLifecycleService.listen(); + + await eventStore.emit(FEATURE_CREATED, { featureName }); + await eventStore.emit(FEATURE_COMPLETED, { featureName }); + await eventStore.emit(CLIENT_METRICS, { + featureName, + environment: 'development', + }); + await eventStore.emit(FEATURE_ARCHIVED, { featureName }); + + const lifecycle = + await featureLifecycleService.getFeatureLifecycle(featureName); + + expect(lifecycle).toEqual([]); +}); diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts index f4b8b75c12..54bc637675 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts @@ -5,6 +5,8 @@ import { FEATURE_CREATED, type IEnvironmentStore, type IEventStore, + type IFlagResolver, + type IUnleashConfig, } from '../../types'; import type { FeatureLifecycleView, @@ -18,35 +20,56 @@ export class FeatureLifecycleService { private environmentStore: IEnvironmentStore; - constructor({ - eventStore, - featureLifecycleStore, - environmentStore, - }: { - eventStore: IEventStore; - environmentStore: IEnvironmentStore; - featureLifecycleStore: IFeatureLifecycleStore; - }) { + private flagResolver: IFlagResolver; + + constructor( + { + eventStore, + featureLifecycleStore, + environmentStore, + }: { + eventStore: IEventStore; + environmentStore: IEnvironmentStore; + featureLifecycleStore: IFeatureLifecycleStore; + }, + { flagResolver }: Pick, + ) { this.eventStore = eventStore; this.featureLifecycleStore = featureLifecycleStore; this.environmentStore = environmentStore; + this.flagResolver = flagResolver; + } + + private async checkEnabled(fn: () => Promise) { + const enabled = this.flagResolver.isEnabled('featureLifecycle'); + if (enabled) { + return fn(); + } } listen() { this.eventStore.on(FEATURE_CREATED, async (event) => { - await this.featureInitialized(event.featureName); + await this.checkEnabled(() => + this.featureInitialized(event.featureName), + ); }); this.eventStore.on(CLIENT_METRICS, async (event) => { - await this.featureReceivedMetrics( - event.featureName, - event.environment, + await this.checkEnabled(() => + this.featureReceivedMetrics( + event.featureName, + event.environment, + ), ); }); this.eventStore.on(FEATURE_COMPLETED, async (event) => { - await this.featureCompleted(event.featureName); + await this.checkEnabled(() => + this.featureCompleted(event.featureName), + ); }); this.eventStore.on(FEATURE_ARCHIVED, async (event) => { - await this.featureArchived(event.featureName); + await this.checkEnabled(() => + this.featureArchived(event.featureName), + ); }); } @@ -54,11 +77,14 @@ export class FeatureLifecycleService { return this.featureLifecycleStore.get(feature); } - async featureInitialized(feature: string) { + private async featureInitialized(feature: string) { await this.featureLifecycleStore.insert({ feature, stage: 'initial' }); } - async stageReceivedMetrics(feature: string, stage: 'live' | 'pre-live') { + private async stageReceivedMetrics( + feature: string, + stage: 'live' | 'pre-live', + ) { const stageExists = await this.featureLifecycleStore.stageExists({ stage, feature, @@ -68,7 +94,7 @@ export class FeatureLifecycleService { } } - async featureReceivedMetrics(feature: string, environment: string) { + private async featureReceivedMetrics(feature: string, environment: string) { const env = await this.environmentStore.get(environment); if (!env) { return; @@ -80,14 +106,14 @@ export class FeatureLifecycleService { } } - async featureCompleted(feature: string) { + private async featureCompleted(feature: string) { await this.featureLifecycleStore.insert({ feature, stage: 'completed', }); } - async featureArchived(feature: string) { + private async featureArchived(feature: string) { await this.featureLifecycleStore.insert({ feature, stage: 'archived' }); } } diff --git a/src/lib/features/index.ts b/src/lib/features/index.ts index 05c5b6035e..a287063eec 100644 --- a/src/lib/features/index.ts +++ b/src/lib/features/index.ts @@ -9,3 +9,4 @@ export * from './tag-type/createTagTypeService'; export * from './project-environments/createEnvironmentService'; export * from './events/createEventsService'; export * from './instance-stats/createInstanceStatsService'; +export * from './feature-lifecycle/createFeatureLifecycle'; diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index db462dd791..3ce2513dd3 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -124,6 +124,8 @@ import { createFakeProjectInsightsService, createProjectInsightsService, } from '../features/project-insights/createProjectInsightsService'; +import { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service'; +import { createFakeFeatureLifecycleService } from '../features/feature-lifecycle/createFeatureLifecycle'; export const createServices = ( stores: IUnleashStores, @@ -347,6 +349,9 @@ export const createServices = ( const inactiveUsersService = new InactiveUsersService(stores, config, { userService, }); + const { featureLifecycleService } = + createFakeFeatureLifecycleService(config); + featureLifecycleService.listen(); return { accessService, @@ -406,6 +411,7 @@ export const createServices = ( featureSearchService, inactiveUsersService, projectInsightsService, + featureLifecycleService, }; }; @@ -453,4 +459,5 @@ export { ClientFeatureToggleService, FeatureSearchService, ProjectInsightsService, + FeatureLifecycleService, }; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 826783c19e..8fb56f86ef 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -57,7 +57,8 @@ export type IFlagKey = | 'variantDependencies' | 'disableShowContextFieldSelectionValues' | 'bearerTokenMiddleware' - | 'projectOverviewRefactorFeedback'; + | 'projectOverviewRefactorFeedback' + | 'featureLifecycle'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -283,6 +284,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_PROJECT_OVERVIEW_REFACTOR_FEEDBACK, false, ), + featureLifecycle: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_FEATURE_LIFECYCLE, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 67fe865bad..18cbb84c43 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -53,6 +53,7 @@ import type { ClientFeatureToggleService } from '../features/client-feature-togg import type { FeatureSearchService } from '../features/feature-search/feature-search-service'; import type { InactiveUsersService } from '../users/inactive/inactive-users-service'; import type { ProjectInsightsService } from '../features/project-insights/project-insights-service'; +import type { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service'; export interface IUnleashServices { accessService: AccessService; @@ -115,4 +116,5 @@ export interface IUnleashServices { featureSearchService: FeatureSearchService; inactiveUsersService: InactiveUsersService; projectInsightsService: ProjectInsightsService; + featureLifecycleService: FeatureLifecycleService; } diff --git a/src/server-dev.ts b/src/server-dev.ts index c66a8b3748..9e6d9edb50 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -54,6 +54,7 @@ process.nextTick(async () => { disableShowContextFieldSelectionValues: false, variantDependencies: true, projectOverviewRefactorFeedback: true, + featureLifecycle: true, }, }, authentication: {