diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 2ea4fb73e6..c9489da2f8 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -76,6 +76,7 @@ exports[`should create default config 1`] = ` "disableNotifications": false, "embedProxy": true, "embedProxyFrontend": true, + "experimentalExtendedTelemetry": false, "featuresExportImport": true, "googleAuthEnabled": false, "groupRootRoles": false, @@ -109,6 +110,7 @@ exports[`should create default config 1`] = ` "disableNotifications": false, "embedProxy": true, "embedProxyFrontend": true, + "experimentalExtendedTelemetry": false, "featuresExportImport": true, "googleAuthEnabled": false, "groupRootRoles": false, diff --git a/src/lib/db/feature-strategy-store.test.ts b/src/lib/db/feature-strategy-store.test.ts new file mode 100644 index 0000000000..14c2b769e8 --- /dev/null +++ b/src/lib/db/feature-strategy-store.test.ts @@ -0,0 +1,95 @@ +import dbInit from '../../test/e2e/helpers/database-init'; +import getLogger from '../../test/fixtures/no-logger'; +import FeatureStrategiesStore from './feature-strategy-store'; +import FeatureToggleStore from './feature-toggle-store'; +import StrategyStore from './strategy-store'; + +let db; + +beforeAll(async () => { + db = await dbInit('feature_strategy_store_serial', getLogger); + getLogger.setMuteError(true); +}); + +afterAll(async () => { + if (db) { + await db.destroy(); + } + getLogger.setMuteError(false); +}); + +test('returns 0 if no custom strategies', async () => { + // Arrange + const featureStrategiesStore: FeatureStrategiesStore = + db.stores.featureStrategiesStore; + + // Act + const inUseCount = + await featureStrategiesStore.getCustomStrategiesInUseCount(); + + // Assert + expect(inUseCount).toEqual(0); +}); + +test('returns 0 if no custom strategies are in use', async () => { + // Arrange + const featureToggleStore: FeatureToggleStore = db.stores.featureToggleStore; + const featureStrategiesStore: FeatureStrategiesStore = + db.stores.featureStrategiesStore; + const strategyStore: StrategyStore = db.stores.strategyStore; + + featureToggleStore.create('default', { + name: 'test-toggle-2', + }); + + strategyStore.createStrategy({ + name: 'strategy-2', + built_in: 0, + parameters: [], + description: '', + createdAt: '2023-06-09T09:00:12.242Z', + }); + + // Act + const inUseCount = + await featureStrategiesStore.getCustomStrategiesInUseCount(); + + // Assert + expect(inUseCount).toEqual(0); +}); + +test('counts custom strategies in use', async () => { + // Arrange + const featureToggleStore: FeatureToggleStore = db.stores.featureToggleStore; + const featureStrategiesStore: FeatureStrategiesStore = + db.stores.featureStrategiesStore; + const strategyStore: StrategyStore = db.stores.strategyStore; + + await featureToggleStore.create('default', { + name: 'test-toggle', + }); + + await strategyStore.createStrategy({ + name: 'strategy-1', + built_in: 0, + parameters: [], + description: '', + createdAt: '2023-06-09T09:00:12.242Z', + }); + + await featureStrategiesStore.createStrategyFeatureEnv({ + projectId: 'default', + featureName: 'test-toggle', + strategyName: 'strategy-1', + environment: 'default', + parameters: {}, + constraints: [], + }); + + // Act + const inUseCount = + await featureStrategiesStore.getCustomStrategiesInUseCount(); + + // Assert + expect(inUseCount).toEqual(1); +}); diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index e4ac30e595..e05121b0a3 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -50,6 +50,7 @@ const T = { featureStrategies: 'feature_strategies', featureStrategySegment: 'feature_strategy_segment', featureEnvs: 'feature_environments', + strategies: 'strategies', }; interface IFeatureStrategiesTable { @@ -665,6 +666,27 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { prefixColumns(): string[] { return COLUMNS.map((c) => `${T.featureStrategies}.${c}`); } + + async getCustomStrategiesInUseCount(): Promise { + const stopTimer = this.timer('getCustomStrategiesInUseCount'); + const notBuiltIn = '0'; + const columns = [ + this.db.raw('count(fes.strategy_name) as times_used'), + 'fes.strategy_name', + ]; + const rows = await this.db(`${T.strategies} as str`) + .select(columns) + .join( + `${T.featureStrategies} as fes`, + 'fes.strategy_name', + 'str.name', + ) + .where(`str.built_in`, '=', notBuiltIn) + .groupBy('strategy_name'); + + stopTimer(); + return rows.length; + } } module.exports = FeatureStrategiesStore; diff --git a/src/lib/services/version-service.test.ts b/src/lib/services/version-service.test.ts index 13aa6d2b54..458321b893 100644 --- a/src/lib/services/version-service.test.ts +++ b/src/lib/services/version-service.test.ts @@ -3,6 +3,7 @@ import createStores from '../../test/fixtures/store'; import version from '../util/version'; import getLogger from '../../test/fixtures/no-logger'; import VersionService from './version-service'; +import { v4 as uuidv4 } from 'uuid'; import { randomId } from '../util/random-id'; beforeAll(() => { @@ -13,10 +14,25 @@ afterAll(() => { nock.enableNetConnect(); }); +const getTestFlagResolver = (enabled: boolean) => { + return { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isEnabled: () => { + return enabled; + }, + getAll: () => { + return {}; + }, + getVariant: () => { + return { name: '', enabled: false }; + }, + }; +}; + test('yields current versions', async () => { const url = `https://${randomId()}.example.com`; - const { settingStore } = createStores(); - await settingStore.insert('instanceInfo', { id: '1234abc' }); + const stores = createStores(); + await stores.settingStore.insert('instanceInfo', { id: '1234abc' }); const latest = { oss: '5.0.0', enterprise: '5.0.0', @@ -30,13 +46,11 @@ test('yields current versions', async () => { versions: latest, }), ]); - const service = new VersionService( - { settingStore }, - { - getLogger, - versionCheck: { url, enable: true }, - }, - ); + const service = new VersionService(stores, { + getLogger, + versionCheck: { url, enable: true }, + flagResolver: getTestFlagResolver(true), + }); await service.checkLatestVersion(); const versionInfo = service.getVersionInfo(); expect(scope.isDone()).toEqual(true); @@ -48,9 +62,9 @@ test('yields current versions', async () => { test('supports setting enterprise version as well', async () => { const url = `https://${randomId()}.example.com`; - const { settingStore } = createStores(); + const stores = createStores(); const enterpriseVersion = '3.7.0'; - await settingStore.insert('instanceInfo', { id: '1234abc' }); + await stores.settingStore.insert('instanceInfo', { id: '1234abc' }); const latest = { oss: '4.0.0', enterprise: '4.0.0', @@ -65,14 +79,12 @@ test('supports setting enterprise version as well', async () => { }), ]); - const service = new VersionService( - { settingStore }, - { - getLogger, - versionCheck: { url, enable: true }, - enterpriseVersion, - }, - ); + const service = new VersionService(stores, { + getLogger, + versionCheck: { url, enable: true }, + enterpriseVersion, + flagResolver: getTestFlagResolver(true), + }); await service.checkLatestVersion(); const versionInfo = service.getVersionInfo(); expect(scope.isDone()).toEqual(true); @@ -84,9 +96,9 @@ test('supports setting enterprise version as well', async () => { test('if version check is not enabled should not make any calls', async () => { const url = `https://${randomId()}.example.com`; - const { settingStore } = createStores(); + const stores = createStores(); const enterpriseVersion = '3.7.0'; - await settingStore.insert('instanceInfo', { id: '1234abc' }); + await stores.settingStore.insert('instanceInfo', { id: '1234abc' }); const latest = { oss: '4.0.0', enterprise: '4.0.0', @@ -101,14 +113,12 @@ test('if version check is not enabled should not make any calls', async () => { }), ]); - const service = new VersionService( - { settingStore }, - { - getLogger, - versionCheck: { url, enable: false }, - enterpriseVersion, - }, - ); + const service = new VersionService(stores, { + getLogger, + versionCheck: { url, enable: false }, + enterpriseVersion, + flagResolver: getTestFlagResolver(true), + }); await service.checkLatestVersion(); const versionInfo = service.getVersionInfo(); expect(scope.isDone()).toEqual(false); @@ -118,3 +128,189 @@ test('if version check is not enabled should not make any calls', async () => { expect(versionInfo.latest.enterprise).toBeFalsy(); nock.cleanAll(); }); + +test('sets featureinfo', async () => { + const url = `https://${randomId()}.example.com`; + const stores = createStores(); + const enterpriseVersion = '4.0.0'; + await stores.settingStore.insert('instanceInfo', { id: '1234abc' }); + const latest = { + oss: '4.0.0', + enterprise: '4.0.0', + }; + + const scope = nock(url) + .post( + '/', + (body) => + body.featureInfo && + body.featureInfo.featureToggles === 0 && + body.featureInfo.environments === 0, + ) + .reply(() => [ + 200, + JSON.stringify({ + latest: true, + versions: latest, + }), + ]); + + const service = new VersionService(stores, { + getLogger, + versionCheck: { url, enable: true }, + enterpriseVersion, + flagResolver: getTestFlagResolver(true), + }); + await service.checkLatestVersion(); + expect(scope.isDone()).toEqual(true); + nock.cleanAll(); +}); + +test('counts toggles', async () => { + const url = `https://${randomId()}.example.com`; + const stores = createStores(); + const enterpriseVersion = '4.0.0'; + await stores.settingStore.insert('instanceInfo', { id: '1234abc' }); + await stores.settingStore.insert('unleash.enterprise.auth.oidc', { + enabled: true, + }); + await stores.featureToggleStore.create('project', { name: uuidv4() }); + await stores.strategyStore.createStrategy({ + name: uuidv4(), + editable: true, + }); + const latest = { + oss: '4.0.0', + enterprise: '4.0.0', + }; + + const scope = nock(url) + .post( + '/', + (body) => + body.featureInfo && + body.featureInfo.featureToggles === 1 && + body.featureInfo.environments === 0 && + body.featureInfo.customStrategies === 1 && + body.featureInfo.customStrategiesInUse === 3 && + body.featureInfo.OIDCenabled, + ) + .reply(() => [ + 200, + JSON.stringify({ + latest: true, + versions: latest, + }), + ]); + + const service = new VersionService(stores, { + getLogger, + versionCheck: { url, enable: true }, + enterpriseVersion, + flagResolver: getTestFlagResolver(true), + }); + await service.checkLatestVersion(); + expect(scope.isDone()).toEqual(true); + nock.cleanAll(); +}); + +test('doesnt report featureinfo when flag off', async () => { + const url = `https://${randomId()}.example.com`; + const stores = createStores(); + const enterpriseVersion = '4.0.0'; + await stores.settingStore.insert('instanceInfo', { id: '1234abc' }); + await stores.settingStore.insert('unleash.enterprise.auth.oidc', { + enabled: true, + }); + await stores.featureToggleStore.create('project', { name: uuidv4() }); + await stores.strategyStore.createStrategy({ + name: uuidv4(), + editable: true, + }); + const latest = { + oss: '4.0.0', + enterprise: '4.0.0', + }; + + const scope = nock(url) + .post('/', (body) => body.featureInfo === undefined) + .reply(() => [ + 200, + JSON.stringify({ + latest: true, + versions: latest, + }), + ]); + + const service = new VersionService(stores, { + getLogger, + versionCheck: { url, enable: true }, + enterpriseVersion, + flagResolver: getTestFlagResolver(false), + }); + await service.checkLatestVersion(); + expect(scope.isDone()).toEqual(true); + nock.cleanAll(); +}); + +test('counts custom strategies', async () => { + const url = `https://${randomId()}.example.com`; + const stores = createStores(); + const enterpriseVersion = '4.0.0'; + const strategyName = uuidv4(); + const toggleName = uuidv4(); + await stores.settingStore.insert('instanceInfo', { id: '1234abc' }); + await stores.settingStore.insert('unleash.enterprise.auth.oidc', { + enabled: true, + }); + await stores.featureToggleStore.create('project', { name: toggleName }); + await stores.strategyStore.createStrategy({ + name: strategyName, + editable: true, + }); + await stores.strategyStore.createStrategy({ + name: uuidv4(), + editable: true, + }); + await stores.featureStrategiesStore.createStrategyFeatureEnv({ + featureName: toggleName, + projectId: 'project', + environment: 'default', + strategyName: strategyName, + parameters: {}, + constraints: [], + }); + const latest = { + oss: '4.0.0', + enterprise: '4.0.0', + }; + + const scope = nock(url) + .post( + '/', + (body) => + body.featureInfo && + body.featureInfo.featureToggles === 1 && + body.featureInfo.environments === 0 && + body.featureInfo.customStrategies === 2 && + body.featureInfo.customStrategiesInUse === 3 && + body.featureInfo.OIDCenabled, + ) + .reply(() => [ + 200, + JSON.stringify({ + latest: true, + versions: latest, + }), + ]); + + const service = new VersionService(stores, { + getLogger, + versionCheck: { url, enable: true }, + enterpriseVersion, + flagResolver: getTestFlagResolver(true), + }); + await service.checkLatestVersion(); + expect(scope.isDone()).toEqual(true); + nock.cleanAll(); +}); diff --git a/src/lib/services/version-service.ts b/src/lib/services/version-service.ts index c9bd991452..5c98a23c0b 100644 --- a/src/lib/services/version-service.ts +++ b/src/lib/services/version-service.ts @@ -1,10 +1,25 @@ import fetch from 'make-fetch-happen'; -import { IUnleashStores } from '../types/stores'; +import { + IContextFieldStore, + IEnvironmentStore, + IEventStore, + IFeatureStrategiesStore, + IFeatureToggleStore, + IGroupStore, + IProjectStore, + IRoleStore, + ISegmentStore, + IUnleashStores, + IUserStore, +} from '../types/stores'; import { IUnleashConfig } from '../types/option'; import version from '../util/version'; import { Logger } from '../logger'; import { ISettingStore } from '../types/stores/settings-store'; import { hoursToMilliseconds } from 'date-fns'; +import { IStrategyStore } from 'lib/types'; +import { FEATURES_EXPORTED, FEATURES_IMPORTED } from '../types'; +import { IFlagResolver } from '../types'; export interface IVersionInfo { oss: string; @@ -23,11 +38,54 @@ export interface IVersionResponse { latest: boolean; } +export interface IFeatureUsageInfo { + instanceId: string; + versionOSS: string; + versionEnterprise?: string; + users: number; + featureToggles: number; + projects: number; + contextFields: number; + roles: number; + featureExports: number; + featureImports: number; + groups: number; + environments: number; + segments: number; + strategies: number; + SAMLenabled: boolean; + OIDCenabled: boolean; + customStrategies: number; + customStrategiesInUse: number; +} + export default class VersionService { private logger: Logger; private settingStore: ISettingStore; + private strategyStore: IStrategyStore; + + private userStore: IUserStore; + + private featureToggleStore: IFeatureToggleStore; + + private projectStore: IProjectStore; + + private environmentStore: IEnvironmentStore; + + private contextFieldStore: IContextFieldStore; + + private groupStore: IGroupStore; + + private roleStore: IRoleStore; + + private segmentStore: ISegmentStore; + + private eventStore: IEventStore; + + private featureStrategiesStore: IFeatureStrategiesStore; + private current: IVersionInfo; private latest?: IVersionInfo; @@ -42,19 +100,60 @@ export default class VersionService { private timer: NodeJS.Timeout; + private flagResolver: IFlagResolver; + constructor( - { settingStore }: Pick, + { + settingStore, + strategyStore, + userStore, + featureToggleStore, + projectStore, + environmentStore, + contextFieldStore, + groupStore, + roleStore, + segmentStore, + eventStore, + featureStrategiesStore, + }: Pick< + IUnleashStores, + | 'settingStore' + | 'strategyStore' + | 'userStore' + | 'featureToggleStore' + | 'projectStore' + | 'environmentStore' + | 'contextFieldStore' + | 'groupStore' + | 'roleStore' + | 'segmentStore' + | 'eventStore' + | 'featureStrategiesStore' + >, { getLogger, versionCheck, enterpriseVersion, + flagResolver, }: Pick< IUnleashConfig, - 'getLogger' | 'versionCheck' | 'enterpriseVersion' + 'getLogger' | 'versionCheck' | 'enterpriseVersion' | 'flagResolver' >, ) { this.logger = getLogger('lib/services/version-service.js'); this.settingStore = settingStore; + this.strategyStore = strategyStore; + this.userStore = userStore; + this.featureToggleStore = featureToggleStore; + this.projectStore = projectStore; + this.environmentStore = environmentStore; + this.contextFieldStore = contextFieldStore; + this.groupStore = groupStore; + this.roleStore = roleStore; + this.segmentStore = segmentStore; + this.eventStore = eventStore; + this.featureStrategiesStore = featureStrategiesStore; this.current = { oss: version, enterprise: enterpriseVersion || '', @@ -62,6 +161,7 @@ export default class VersionService { this.enabled = versionCheck.enable; this.versionCheckUrl = versionCheck.url; this.isLatest = true; + this.flagResolver = flagResolver; process.nextTick(() => this.setup()); } @@ -87,12 +187,20 @@ export default class VersionService { async checkLatestVersion(): Promise { if (this.enabled) { try { + const versionPayload: any = { + versions: this.current, + instanceId: this.instanceId, + }; + + if ( + this.flagResolver.isEnabled('experimentalExtendedTelemetry') + ) { + const featureInfo = await this.getFeatureUsageInfo(); + versionPayload.featureInfo = featureInfo; + } const res = await fetch(this.versionCheckUrl, { method: 'POST', - body: JSON.stringify({ - versions: this.current, - instanceId: this.instanceId, - }), + body: JSON.stringify(versionPayload), headers: { 'Content-Type': 'application/json' }, }); if (res.ok) { @@ -113,6 +221,82 @@ export default class VersionService { } } + async getFeatureUsageInfo(): Promise { + const [ + featureToggles, + users, + projects, + contextFields, + groups, + roles, + environments, + segments, + strategies, + SAMLenabled, + OIDCenabled, + featureExports, + featureImports, + ] = await Promise.all([ + this.featureToggleStore.count({ + archived: false, + }), + this.userStore.count(), + this.projectStore.count(), + this.contextFieldStore.count(), + this.groupStore.count(), + this.roleStore.count(), + this.environmentStore.count(), + this.segmentStore.count(), + this.strategyStore.count(), + this.hasSAML(), + this.hasOIDC(), + this.eventStore.filteredCount({ type: FEATURES_EXPORTED }), + this.eventStore.filteredCount({ type: FEATURES_IMPORTED }), + ]); + const versionInfo = this.getVersionInfo(); + const customStrategies = + await this.strategyStore.getEditableStrategies(); + const customStrategiesInUse = + await this.featureStrategiesStore.getCustomStrategiesInUseCount(); + const featureInfo = { + featureToggles, + users, + projects, + contextFields, + groups, + roles, + environments, + segments, + strategies, + SAMLenabled, + OIDCenabled, + featureExports, + featureImports, + customStrategies: customStrategies.length, + customStrategiesInUse: customStrategiesInUse, + instanceId: versionInfo.instanceId, + versionOSS: versionInfo.current.oss, + versionEnterprise: versionInfo.current.enterprise, + }; + return featureInfo; + } + + async hasOIDC(): Promise { + const settings = await this.settingStore.get( + 'unleash.enterprise.auth.oidc', + ); + + return settings?.enabled || false; + } + + async hasSAML(): Promise { + const settings = await this.settingStore.get( + 'unleash.enterprise.auth.saml', + ); + + return settings?.enabled || false; + } + getVersionInfo(): IVersionHolder { return { current: this.current, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index bb6950af69..e6995dafec 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -22,6 +22,7 @@ export type IFlagKey = | 'googleAuthEnabled' | 'variantMetrics' | 'disableBulkToggle' + | 'experimentalExtendedTelemetry' | 'segmentContextFieldUsage' | 'disableNotifications' | 'advancedPlayground'; @@ -97,6 +98,10 @@ const flags: IFlags = { process.env.UNLEASH_VARIANT_METRICS, false, ), + experimentalExtendedTelemetry: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_EXTENDED_TELEMETRY, + false, + ), disableBulkToggle: parseEnvVarBoolean( process.env.DISABLE_BULK_TOGGLE, false, diff --git a/src/lib/types/stores/feature-strategies-store.ts b/src/lib/types/stores/feature-strategies-store.ts index 7cb7f13eb4..7e76500247 100644 --- a/src/lib/types/stores/feature-strategies-store.ts +++ b/src/lib/types/stores/feature-strategies-store.ts @@ -66,4 +66,5 @@ export interface IFeatureStrategiesStore features: string[], environment?: string, ): Promise; + getCustomStrategiesInUseCount(): Promise; } diff --git a/src/test/fixtures/fake-feature-strategies-store.ts b/src/test/fixtures/fake-feature-strategies-store.ts index 79ad134f0a..63718f8e3e 100644 --- a/src/test/fixtures/fake-feature-strategies-store.ts +++ b/src/test/fixtures/fake-feature-strategies-store.ts @@ -334,6 +334,10 @@ export default class FakeFeatureStrategiesStore ), ); } + + getCustomStrategiesInUseCount(): Promise { + return Promise.resolve(3); + } } module.exports = FakeFeatureStrategiesStore;