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

feat: add instance stats to version check (#3835)

<!-- Thanks for creating a PR! To make it easier for reviewers and
everyone else to understand what your changes relate to, please add some
relevant content to the headings below. Feel free to ignore or delete
sections that you don't think are relevant. Thank you! ❤️ -->

## About the changes
<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->

Adds feature usage info and custom strategy counters to the version
check object.

<!-- Does it close an issue? Multiple? -->
Closes #

<!-- (For internal contributors): Does it relate to an issue on public
roadmap? -->
<!--
Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#
-->

### Important files
<!-- PRs can contain a lot of changes, but not all changes are equally
important. Where should a reviewer start looking to get an overview of
the changes? Are any files particularly important? -->


## Discussion points
<!-- Anything about the PR you'd like to discuss before it gets merged?
Got any questions or doubts? -->
This commit is contained in:
David Leek 2023-06-13 15:54:20 +02:00 committed by GitHub
parent 5f3e5729b9
commit 98d315e062
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 545 additions and 36 deletions

View File

@ -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,

View File

@ -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);
});

View File

@ -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<number> {
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;

View File

@ -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();
});

View File

@ -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<IUnleashStores, 'settingStore'>,
{
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<void> {
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<IFeatureUsageInfo> {
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<boolean> {
const settings = await this.settingStore.get(
'unleash.enterprise.auth.oidc',
);
return settings?.enabled || false;
}
async hasSAML(): Promise<boolean> {
const settings = await this.settingStore.get(
'unleash.enterprise.auth.saml',
);
return settings?.enabled || false;
}
getVersionInfo(): IVersionHolder {
return {
current: this.current,

View File

@ -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,

View File

@ -66,4 +66,5 @@ export interface IFeatureStrategiesStore
features: string[],
environment?: string,
): Promise<IFeatureStrategy[]>;
getCustomStrategiesInUseCount(): Promise<number>;
}

View File

@ -334,6 +334,10 @@ export default class FakeFeatureStrategiesStore
),
);
}
getCustomStrategiesInUseCount(): Promise<number> {
return Promise.resolve(3);
}
}
module.exports = FakeFeatureStrategiesStore;