mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-10 01:16:39 +02:00
feat: feature changes counted in new table (#4958)
As part of more telemetry on the usage of Unleash. This PR adds a new `stat_` prefixed table as well as a trigger on the events table trigger on each insert to increment a counter per environment per day. The trigger will trigger on every insert into the events base, but will filter and only increment the counter for events that actually have the environment set. (there are events, like user-created, that does not relate to a specific environment). Bit wary on this, but since we truncate down to row per (day, environment) combo, finding conflict and incrementing shouldn't take too long here. @ivarconr was it something like this you were considering?
This commit is contained in:
parent
fa4d6b211a
commit
1edd73db45
@ -146,9 +146,7 @@ class EventStore implements IEventStore {
|
||||
|
||||
async batchStore(events: IBaseEvent[]): Promise<void> {
|
||||
try {
|
||||
await this.db(TABLE)
|
||||
.insert(events.map(this.eventToDbRow))
|
||||
.returning(EVENT_COLUMNS);
|
||||
await this.db(TABLE).insert(events.map(this.eventToDbRow));
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(`Failed to store events: ${error}`);
|
||||
}
|
||||
|
153
src/lib/features/instance-stats/getProductionChanges.e2e.test.ts
Normal file
153
src/lib/features/instance-stats/getProductionChanges.e2e.test.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
|
||||
import getLogger from '../../../test/fixtures/no-logger';
|
||||
import {
|
||||
createGetProductionChanges,
|
||||
GetProductionChanges,
|
||||
} from './getProductionChanges';
|
||||
import subDays from 'date-fns/subDays';
|
||||
let db: ITestDb;
|
||||
let getProductionChanges: GetProductionChanges;
|
||||
|
||||
const mockEventDaysAgo = (days: number, environment: string = 'production') => {
|
||||
const result = new Date();
|
||||
result.setDate(result.getDate() - days);
|
||||
return {
|
||||
day: result,
|
||||
environment,
|
||||
updates: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const mockRawEventDaysAgo = (
|
||||
days: number,
|
||||
environment: string = 'production',
|
||||
) => {
|
||||
const date = subDays(new Date(), days);
|
||||
return {
|
||||
type: 'FEATURE_UPDATED',
|
||||
created_at: date,
|
||||
created_by: 'testrunner',
|
||||
environment,
|
||||
feature_name: 'test.feature',
|
||||
announced: true,
|
||||
};
|
||||
};
|
||||
|
||||
const noEnvironmentEvent = (days: number) => {
|
||||
return {
|
||||
type: 'FEATURE_UPDATED',
|
||||
created_by: 'testrunner',
|
||||
feature_name: 'test.feature',
|
||||
announced: true,
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('product_changes_serial', getLogger);
|
||||
await db.rawDatabase('environments').insert({
|
||||
name: 'production',
|
||||
type: 'production',
|
||||
enabled: true,
|
||||
protected: false,
|
||||
});
|
||||
getProductionChanges = createGetProductionChanges(db.rawDatabase);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.rawDatabase('stat_environment_updates').truncate();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('should return 0 changes from an empty database', async () => {
|
||||
await expect(getProductionChanges()).resolves.toEqual({
|
||||
last30: 0,
|
||||
last60: 0,
|
||||
last90: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 1 change', async () => {
|
||||
await db
|
||||
.rawDatabase('stat_environment_updates')
|
||||
.insert(mockEventDaysAgo(1));
|
||||
|
||||
await expect(getProductionChanges()).resolves.toEqual({
|
||||
last30: 1,
|
||||
last60: 1,
|
||||
last90: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle intervals of activity', async () => {
|
||||
await db
|
||||
.rawDatabase('stat_environment_updates')
|
||||
.insert([
|
||||
mockEventDaysAgo(5),
|
||||
mockEventDaysAgo(10),
|
||||
mockEventDaysAgo(20),
|
||||
mockEventDaysAgo(40),
|
||||
mockEventDaysAgo(70),
|
||||
mockEventDaysAgo(100),
|
||||
]);
|
||||
|
||||
await expect(getProductionChanges()).resolves.toEqual({
|
||||
last30: 3,
|
||||
last60: 4,
|
||||
last90: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('an event being saved should add a count to the table', async () => {
|
||||
await db.rawDatabase
|
||||
.table('events')
|
||||
.insert(mockRawEventDaysAgo(70))
|
||||
.returning('id');
|
||||
|
||||
await expect(getProductionChanges()).resolves.toEqual({
|
||||
last30: 0,
|
||||
last60: 0,
|
||||
last90: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('an event with no environment should not be counted', async () => {
|
||||
await db.rawDatabase('events').insert(noEnvironmentEvent(30));
|
||||
await expect(getProductionChanges()).resolves.toEqual({
|
||||
last30: 0,
|
||||
last60: 0,
|
||||
last90: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('five events per day should be counted correctly', async () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
for (let j: number = 0; j < 5; j++) {
|
||||
await db.rawDatabase.table('events').insert(mockRawEventDaysAgo(i));
|
||||
}
|
||||
}
|
||||
await expect(getProductionChanges()).resolves.toEqual({
|
||||
last30: 150,
|
||||
last60: 300,
|
||||
last90: 450,
|
||||
});
|
||||
});
|
||||
|
||||
test('Events posted to a non production environment should not be included in count', async () => {
|
||||
await db.rawDatabase('environments').insert({
|
||||
name: 'development',
|
||||
type: 'development',
|
||||
enabled: true,
|
||||
protected: false,
|
||||
});
|
||||
await db.rawDatabase
|
||||
.table('events')
|
||||
.insert(mockRawEventDaysAgo(1, 'development'));
|
||||
await expect(getProductionChanges()).resolves.toEqual({
|
||||
last30: 0,
|
||||
last60: 0,
|
||||
last90: 0,
|
||||
});
|
||||
});
|
41
src/lib/features/instance-stats/getProductionChanges.ts
Normal file
41
src/lib/features/instance-stats/getProductionChanges.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { type Db } from 'lib/server-impl';
|
||||
import { GetActiveUsers } from './getActiveUsers';
|
||||
|
||||
export type GetProductionChanges = () => Promise<{
|
||||
last30: number;
|
||||
last60: number;
|
||||
last90: number;
|
||||
}>;
|
||||
|
||||
export const createGetProductionChanges =
|
||||
(db: Db): GetProductionChanges =>
|
||||
async () => {
|
||||
const productionChanges = await db.raw(`SELECT SUM(CASE WHEN seu.day > NOW() - INTERVAL '30 days' THEN seu.updates END) AS last_month,
|
||||
SUM(CASE WHEN seu.day > NOW() - INTERVAL '60 days' THEN seu.updates END) AS last_two_months,
|
||||
SUM(CASE WHEN seu.day > NOW() - INTERVAL '90 days' THEN seu.updates END) AS last_quarter
|
||||
FROM stat_environment_updates seu
|
||||
LEFT JOIN environments e
|
||||
ON e.name = seu.environment
|
||||
WHERE e.type = 'production';`);
|
||||
return {
|
||||
last30: parseInt(productionChanges.rows[0]?.last_month || '0', 10),
|
||||
last60: parseInt(
|
||||
productionChanges.rows[0]?.last_two_months || '0',
|
||||
10,
|
||||
),
|
||||
last90: parseInt(
|
||||
productionChanges.rows[0]?.last_quarter || '0',
|
||||
10,
|
||||
),
|
||||
};
|
||||
};
|
||||
export const createFakeGetProductionChanges =
|
||||
(
|
||||
changesInProduction: Awaited<ReturnType<GetProductionChanges>> = {
|
||||
last30: 0,
|
||||
last60: 0,
|
||||
last90: 0,
|
||||
},
|
||||
): GetProductionChanges =>
|
||||
() =>
|
||||
Promise.resolve(changesInProduction);
|
@ -3,6 +3,7 @@ import { InstanceStatsService } from './instance-stats-service';
|
||||
import createStores from '../../../test/fixtures/store';
|
||||
import VersionService from '../../services/version-service';
|
||||
import { createFakeGetActiveUsers } from './getActiveUsers';
|
||||
import { createFakeGetProductionChanges } from './getProductionChanges';
|
||||
|
||||
let instanceStatsService: InstanceStatsService;
|
||||
let versionService: VersionService;
|
||||
@ -10,12 +11,18 @@ let versionService: VersionService;
|
||||
beforeEach(() => {
|
||||
const config = createTestConfig();
|
||||
const stores = createStores();
|
||||
versionService = new VersionService(stores, config);
|
||||
versionService = new VersionService(
|
||||
stores,
|
||||
config,
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
instanceStatsService = new InstanceStatsService(
|
||||
stores,
|
||||
config,
|
||||
versionService,
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
|
||||
jest.spyOn(instanceStatsService, 'refreshStatsSnapshot');
|
||||
|
@ -21,6 +21,7 @@ import { FEATURES_EXPORTED, FEATURES_IMPORTED } from '../../types';
|
||||
import { CUSTOM_ROOT_ROLE_TYPE } from '../../util';
|
||||
import { type GetActiveUsers } from './getActiveUsers';
|
||||
import { ProjectModeCount } from '../../db/project-store';
|
||||
import { GetProductionChanges } from './getProductionChanges';
|
||||
|
||||
export type TimeRange = 'allTime' | '30d' | '7d';
|
||||
|
||||
@ -46,6 +47,7 @@ export interface InstanceStats {
|
||||
OIDCenabled: boolean;
|
||||
clientApps: { range: TimeRange; count: number }[];
|
||||
activeUsers: Awaited<ReturnType<GetActiveUsers>>;
|
||||
productionChanges: Awaited<ReturnType<GetProductionChanges>>;
|
||||
}
|
||||
|
||||
export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & {
|
||||
@ -88,6 +90,8 @@ export class InstanceStatsService {
|
||||
|
||||
private getActiveUsers: GetActiveUsers;
|
||||
|
||||
private getProductionChanges: GetProductionChanges;
|
||||
|
||||
constructor(
|
||||
{
|
||||
featureToggleStore,
|
||||
@ -120,6 +124,7 @@ export class InstanceStatsService {
|
||||
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
|
||||
versionService: VersionService,
|
||||
getActiveUsers: GetActiveUsers,
|
||||
getProductionChanges: GetProductionChanges,
|
||||
) {
|
||||
this.strategyStore = strategyStore;
|
||||
this.userStore = userStore;
|
||||
@ -136,6 +141,7 @@ export class InstanceStatsService {
|
||||
this.clientInstanceStore = clientInstanceStore;
|
||||
this.logger = getLogger('services/stats-service.js');
|
||||
this.getActiveUsers = getActiveUsers;
|
||||
this.getProductionChanges = getProductionChanges;
|
||||
}
|
||||
|
||||
async refreshStatsSnapshot(): Promise<void> {
|
||||
@ -203,6 +209,7 @@ export class InstanceStatsService {
|
||||
clientApps,
|
||||
featureExports,
|
||||
featureImports,
|
||||
productionChanges,
|
||||
] = await Promise.all([
|
||||
this.getToggleCount(),
|
||||
this.userStore.count(),
|
||||
@ -221,6 +228,7 @@ export class InstanceStatsService {
|
||||
this.getLabeledAppCounts(),
|
||||
this.eventStore.filteredCount({ type: FEATURES_EXPORTED }),
|
||||
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }),
|
||||
this.getProductionChanges(),
|
||||
]);
|
||||
|
||||
return {
|
||||
@ -245,6 +253,7 @@ export class InstanceStatsService {
|
||||
clientApps,
|
||||
featureExports,
|
||||
featureImports,
|
||||
productionChanges,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ import createStores from '../test/fixtures/store';
|
||||
import { InstanceStatsService } from './features/instance-stats/instance-stats-service';
|
||||
import VersionService from './services/version-service';
|
||||
import { createFakeGetActiveUsers } from './features/instance-stats/getActiveUsers';
|
||||
import { createFakeGetProductionChanges } from './features/instance-stats/getProductionChanges';
|
||||
|
||||
const monitor = createMetricsMonitor();
|
||||
const eventBus = new EventEmitter();
|
||||
@ -28,12 +29,18 @@ beforeAll(() => {
|
||||
});
|
||||
stores = createStores();
|
||||
eventStore = stores.eventStore;
|
||||
const versionService = new VersionService(stores, config);
|
||||
const versionService = new VersionService(
|
||||
stores,
|
||||
config,
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
statsService = new InstanceStatsService(
|
||||
stores,
|
||||
config,
|
||||
versionService,
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
|
||||
const db = {
|
||||
|
@ -174,6 +174,22 @@ export default class MetricsMonitor {
|
||||
labelNames: ['status'],
|
||||
});
|
||||
|
||||
const productionChanges30 = new client.Gauge({
|
||||
name: 'production_changes_30',
|
||||
help: 'Changes made to production environment last 30 days',
|
||||
labelNames: ['environment'],
|
||||
});
|
||||
const productionChanges60 = new client.Gauge({
|
||||
name: 'production_changes_60',
|
||||
help: 'Changes made to production environment last 60 days',
|
||||
labelNames: ['environment'],
|
||||
});
|
||||
const productionChanges90 = new client.Gauge({
|
||||
name: 'production_changes_90',
|
||||
help: 'Changes made to production environment last 90 days',
|
||||
labelNames: ['environment'],
|
||||
});
|
||||
|
||||
async function collectStaticCounters() {
|
||||
try {
|
||||
const stats = await instanceStatsService.getStats();
|
||||
@ -193,6 +209,13 @@ export default class MetricsMonitor {
|
||||
usersActive90days.reset();
|
||||
usersActive90days.set(stats.activeUsers.last90);
|
||||
|
||||
productionChanges30.reset();
|
||||
productionChanges30.set(stats.productionChanges.last30);
|
||||
productionChanges60.reset();
|
||||
productionChanges60.set(stats.productionChanges.last60);
|
||||
productionChanges90.reset();
|
||||
productionChanges90.set(stats.productionChanges.last90);
|
||||
|
||||
projectsTotal.reset();
|
||||
stats.projects.forEach((projectStat) => {
|
||||
projectsTotal
|
||||
|
@ -74,6 +74,34 @@ export const instanceAdminStatsSchema = {
|
||||
},
|
||||
},
|
||||
},
|
||||
productionChanges: {
|
||||
type: 'object',
|
||||
description:
|
||||
'The number of changes to the production environment in the last 30, 60 and 90 days',
|
||||
properties: {
|
||||
last30: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The number of changes in production in the last 30 days',
|
||||
example: 10,
|
||||
minimum: 0,
|
||||
},
|
||||
last60: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The number of changes in production in the last 60 days',
|
||||
example: 12,
|
||||
minimum: 0,
|
||||
},
|
||||
last90: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The number of changes in production in the last 90 days',
|
||||
example: 15,
|
||||
minimum: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
featureToggles: {
|
||||
type: 'number',
|
||||
description: 'The number of feature-toggles this instance has',
|
||||
|
@ -113,6 +113,11 @@ class InstanceAdminController extends Controller {
|
||||
last30: 10,
|
||||
last7: 5,
|
||||
},
|
||||
productionChanges: {
|
||||
last30: 100,
|
||||
last60: 200,
|
||||
last90: 200,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -85,6 +85,10 @@ import {
|
||||
createFakeLastSeenService,
|
||||
createLastSeenService,
|
||||
} from './client-metrics/last-seen/createLastSeenService';
|
||||
import {
|
||||
createFakeGetProductionChanges,
|
||||
createGetProductionChanges,
|
||||
} from '../features/instance-stats/getProductionChanges';
|
||||
|
||||
// TODO: will be moved to scheduler feature directory
|
||||
export const scheduleServices = async (
|
||||
@ -226,7 +230,19 @@ export const createServices = (
|
||||
const accountService = new AccountService(stores, config, {
|
||||
accessService,
|
||||
});
|
||||
const versionService = new VersionService(stores, config);
|
||||
const getActiveUsers = db
|
||||
? createGetActiveUsers(db)
|
||||
: createFakeGetActiveUsers();
|
||||
const getProductionChanges = db
|
||||
? createGetProductionChanges(db)
|
||||
: createFakeGetProductionChanges();
|
||||
|
||||
const versionService = new VersionService(
|
||||
stores,
|
||||
config,
|
||||
getActiveUsers,
|
||||
getProductionChanges,
|
||||
);
|
||||
const healthService = new HealthService(stores, config);
|
||||
const userFeedbackService = new UserFeedbackService(stores, config);
|
||||
const changeRequestAccessReadModel = db
|
||||
@ -330,6 +346,7 @@ export const createServices = (
|
||||
config,
|
||||
versionService,
|
||||
db ? createGetActiveUsers(db) : createFakeGetActiveUsers(),
|
||||
db ? createGetProductionChanges(db) : createFakeGetProductionChanges(),
|
||||
);
|
||||
|
||||
const schedulerService = new SchedulerService(config.getLogger);
|
||||
|
@ -5,6 +5,8 @@ import getLogger from '../../test/fixtures/no-logger';
|
||||
import VersionService from './version-service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { randomId } from '../util/random-id';
|
||||
import { createFakeGetActiveUsers } from '../features/instance-stats/getActiveUsers';
|
||||
import { createFakeGetProductionChanges } from '../features/instance-stats/getProductionChanges';
|
||||
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
@ -31,11 +33,16 @@ test('yields current versions', async () => {
|
||||
versions: latest,
|
||||
}),
|
||||
]);
|
||||
const service = new VersionService(stores, {
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
telemetry: true,
|
||||
});
|
||||
const service = new VersionService(
|
||||
stores,
|
||||
{
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
telemetry: true,
|
||||
},
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
await service.checkLatestVersion();
|
||||
const versionInfo = service.getVersionInfo();
|
||||
expect(scope.isDone()).toEqual(true);
|
||||
@ -64,12 +71,17 @@ test('supports setting enterprise version as well', async () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const service = new VersionService(stores, {
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
});
|
||||
const service = new VersionService(
|
||||
stores,
|
||||
{
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
},
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
await service.checkLatestVersion();
|
||||
const versionInfo = service.getVersionInfo();
|
||||
expect(scope.isDone()).toEqual(true);
|
||||
@ -98,12 +110,17 @@ test('if version check is not enabled should not make any calls', async () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const service = new VersionService(stores, {
|
||||
getLogger,
|
||||
versionCheck: { url, enable: false },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
});
|
||||
const service = new VersionService(
|
||||
stores,
|
||||
{
|
||||
getLogger,
|
||||
versionCheck: { url, enable: false },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
},
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
await service.checkLatestVersion();
|
||||
const versionInfo = service.getVersionInfo();
|
||||
expect(scope.isDone()).toEqual(false);
|
||||
@ -140,12 +157,17 @@ test('sets featureinfo', async () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const service = new VersionService(stores, {
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
});
|
||||
const service = new VersionService(
|
||||
stores,
|
||||
{
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
},
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
await service.checkLatestVersion();
|
||||
expect(scope.isDone()).toEqual(true);
|
||||
nock.cleanAll();
|
||||
@ -189,12 +211,17 @@ test('counts toggles', async () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const service = new VersionService(stores, {
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
});
|
||||
const service = new VersionService(
|
||||
stores,
|
||||
{
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
},
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
await service.checkLatestVersion();
|
||||
expect(scope.isDone()).toEqual(true);
|
||||
nock.cleanAll();
|
||||
@ -253,12 +280,113 @@ test('counts custom strategies', async () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const service = new VersionService(stores, {
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
});
|
||||
const service = new VersionService(
|
||||
stores,
|
||||
{
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
},
|
||||
createFakeGetActiveUsers(),
|
||||
createFakeGetProductionChanges(),
|
||||
);
|
||||
await service.checkLatestVersion();
|
||||
expect(scope.isDone()).toEqual(true);
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
test('counts active users', 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 fakeActiveUsers = createFakeGetActiveUsers({
|
||||
last7: 2,
|
||||
last30: 5,
|
||||
last60: 10,
|
||||
last90: 20,
|
||||
});
|
||||
const fakeProductionChanges = createFakeGetProductionChanges();
|
||||
const scope = nock(url)
|
||||
.post(
|
||||
'/',
|
||||
(body) =>
|
||||
body.featureInfo &&
|
||||
body.featureInfo.activeUsers30 === 5 &&
|
||||
body.featureInfo.activeUsers60 === 10 &&
|
||||
body.featureInfo.activeUsers90 === 20,
|
||||
)
|
||||
.reply(() => [
|
||||
200,
|
||||
JSON.stringify({
|
||||
latest: true,
|
||||
versions: latest,
|
||||
}),
|
||||
]);
|
||||
|
||||
const service = new VersionService(
|
||||
stores,
|
||||
{
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
},
|
||||
fakeActiveUsers,
|
||||
fakeProductionChanges,
|
||||
);
|
||||
await service.checkLatestVersion();
|
||||
expect(scope.isDone()).toEqual(true);
|
||||
nock.cleanAll();
|
||||
});
|
||||
test('Counts production changes', 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 fakeActiveUsers = createFakeGetActiveUsers();
|
||||
const fakeProductionChanges = createFakeGetProductionChanges({
|
||||
last30: 5,
|
||||
last60: 10,
|
||||
last90: 20,
|
||||
});
|
||||
const scope = nock(url)
|
||||
.post(
|
||||
'/',
|
||||
(body) =>
|
||||
body.featureInfo &&
|
||||
body.featureInfo.productionChanges30 === 5 &&
|
||||
body.featureInfo.productionChanges60 === 10 &&
|
||||
body.featureInfo.productionChanges90 === 20,
|
||||
)
|
||||
.reply(() => [
|
||||
200,
|
||||
JSON.stringify({
|
||||
latest: true,
|
||||
versions: latest,
|
||||
}),
|
||||
]);
|
||||
|
||||
const service = new VersionService(
|
||||
stores,
|
||||
{
|
||||
getLogger,
|
||||
versionCheck: { url, enable: true },
|
||||
enterpriseVersion,
|
||||
telemetry: true,
|
||||
},
|
||||
fakeActiveUsers,
|
||||
fakeProductionChanges,
|
||||
);
|
||||
await service.checkLatestVersion();
|
||||
expect(scope.isDone()).toEqual(true);
|
||||
nock.cleanAll();
|
||||
|
@ -20,6 +20,14 @@ import { hoursToMilliseconds } from 'date-fns';
|
||||
import { IStrategyStore } from 'lib/types';
|
||||
import { FEATURES_EXPORTED, FEATURES_IMPORTED } from '../types';
|
||||
import { CUSTOM_ROOT_ROLE_TYPE } from '../util';
|
||||
import {
|
||||
createGetActiveUsers,
|
||||
GetActiveUsers,
|
||||
} from '../features/instance-stats/getActiveUsers';
|
||||
import {
|
||||
createGetProductionChanges,
|
||||
GetProductionChanges,
|
||||
} from '../features/instance-stats/getProductionChanges';
|
||||
|
||||
export interface IVersionInfo {
|
||||
oss: string;
|
||||
@ -58,6 +66,12 @@ export interface IFeatureUsageInfo {
|
||||
OIDCenabled: boolean;
|
||||
customStrategies: number;
|
||||
customStrategiesInUse: number;
|
||||
activeUsers30: number;
|
||||
activeUsers60: number;
|
||||
activeUsers90: number;
|
||||
productionChanges30: number;
|
||||
productionChanges60: number;
|
||||
productionChanges90: number;
|
||||
}
|
||||
|
||||
export default class VersionService {
|
||||
@ -87,6 +101,10 @@ export default class VersionService {
|
||||
|
||||
private featureStrategiesStore: IFeatureStrategiesStore;
|
||||
|
||||
private getActiveUsers: GetActiveUsers;
|
||||
|
||||
private getProductionChanges: GetProductionChanges;
|
||||
|
||||
private current: IVersionInfo;
|
||||
|
||||
private latest?: IVersionInfo;
|
||||
@ -95,7 +113,7 @@ export default class VersionService {
|
||||
|
||||
private telemetryEnabled: boolean;
|
||||
|
||||
private versionCheckUrl: string;
|
||||
private versionCheckUrl?: string;
|
||||
|
||||
private instanceId?: string;
|
||||
|
||||
@ -141,6 +159,8 @@ export default class VersionService {
|
||||
IUnleashConfig,
|
||||
'getLogger' | 'versionCheck' | 'enterpriseVersion' | 'telemetry'
|
||||
>,
|
||||
getActiveUsers: GetActiveUsers,
|
||||
getProductionChanges: GetProductionChanges,
|
||||
) {
|
||||
this.logger = getLogger('lib/services/version-service.js');
|
||||
this.settingStore = settingStore;
|
||||
@ -154,12 +174,14 @@ export default class VersionService {
|
||||
this.roleStore = roleStore;
|
||||
this.segmentStore = segmentStore;
|
||||
this.eventStore = eventStore;
|
||||
this.getActiveUsers = getActiveUsers;
|
||||
this.getProductionChanges = getProductionChanges;
|
||||
this.featureStrategiesStore = featureStrategiesStore;
|
||||
this.current = {
|
||||
oss: version,
|
||||
enterprise: enterpriseVersion || '',
|
||||
};
|
||||
this.enabled = versionCheck.enable;
|
||||
this.enabled = versionCheck.enable || false;
|
||||
this.telemetryEnabled = telemetry;
|
||||
this.versionCheckUrl = versionCheck.url;
|
||||
this.isLatest = true;
|
||||
@ -194,25 +216,29 @@ export default class VersionService {
|
||||
};
|
||||
|
||||
if (this.telemetryEnabled) {
|
||||
const featureInfo = await this.getFeatureUsageInfo();
|
||||
versionPayload.featureInfo = featureInfo;
|
||||
versionPayload.featureInfo =
|
||||
await this.getFeatureUsageInfo();
|
||||
}
|
||||
const res = await fetch(this.versionCheckUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(versionPayload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as IVersionResponse;
|
||||
this.latest = {
|
||||
oss: data.versions.oss,
|
||||
enterprise: data.versions.enterprise,
|
||||
};
|
||||
this.isLatest = data.latest;
|
||||
if (this.versionCheckUrl) {
|
||||
const res = await fetch(this.versionCheckUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(versionPayload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as IVersionResponse;
|
||||
this.latest = {
|
||||
oss: data.versions.oss,
|
||||
enterprise: data.versions.enterprise,
|
||||
};
|
||||
this.isLatest = data.latest;
|
||||
} else {
|
||||
this.logger.info(
|
||||
`Could not check newest version. Status: ${res.status}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.info(
|
||||
`Could not check newest version. Status: ${res.status}`,
|
||||
);
|
||||
this.logger.info('Had no URL to check newest version');
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info('Could not check newest version', err);
|
||||
@ -237,6 +263,8 @@ export default class VersionService {
|
||||
OIDCenabled,
|
||||
featureExports,
|
||||
featureImports,
|
||||
userActive,
|
||||
productionChanges,
|
||||
] = await Promise.all([
|
||||
this.featureToggleStore.count({
|
||||
archived: false,
|
||||
@ -257,6 +285,8 @@ export default class VersionService {
|
||||
this.hasOIDC(),
|
||||
this.eventStore.filteredCount({ type: FEATURES_EXPORTED }),
|
||||
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }),
|
||||
this.userStats(),
|
||||
this.productionChanges(),
|
||||
]);
|
||||
const versionInfo = this.getVersionInfo();
|
||||
const customStrategies =
|
||||
@ -284,10 +314,33 @@ export default class VersionService {
|
||||
instanceId: versionInfo.instanceId,
|
||||
versionOSS: versionInfo.current.oss,
|
||||
versionEnterprise: versionInfo.current.enterprise,
|
||||
activeUsers30: userActive.last30,
|
||||
activeUsers60: userActive.last60,
|
||||
activeUsers90: userActive.last90,
|
||||
productionChanges30: productionChanges.last30,
|
||||
productionChanges60: productionChanges.last60,
|
||||
productionChanges90: productionChanges.last90,
|
||||
};
|
||||
return featureInfo;
|
||||
}
|
||||
|
||||
async userStats(): Promise<{
|
||||
last30: number;
|
||||
last60: number;
|
||||
last90: number;
|
||||
}> {
|
||||
const { last30, last60, last90 } = await this.getActiveUsers();
|
||||
return { last30, last60, last90 };
|
||||
}
|
||||
|
||||
async productionChanges(): Promise<{
|
||||
last30: number;
|
||||
last60: number;
|
||||
last90: number;
|
||||
}> {
|
||||
return this.getProductionChanges();
|
||||
}
|
||||
|
||||
async hasOIDC(): Promise<boolean> {
|
||||
const settings = await this.settingStore.get(
|
||||
'unleash.enterprise.auth.oidc',
|
||||
|
@ -0,0 +1,32 @@
|
||||
exports.up = function(db, cb) {
|
||||
db.runSql(`
|
||||
CREATE TABLE stat_environment_updates(
|
||||
day DATE NOT NULL,
|
||||
environment TEXT,
|
||||
updates BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (day, environment)
|
||||
);
|
||||
|
||||
CREATE FUNCTION unleash_update_stat_environment_changes_counter() RETURNS trigger AS $unleash_update_changes_counter$
|
||||
BEGIN
|
||||
IF NEW.environment IS NOT NULL THEN
|
||||
INSERT INTO stat_environment_updates(day, environment, updates) SELECT DATE_TRUNC('Day', NEW.created_at), NEW.environment, 1 ON CONFLICT (day, environment) DO UPDATE SET updates = stat_environment_updates.updates + 1;
|
||||
END IF;
|
||||
|
||||
return null;
|
||||
END;
|
||||
$unleash_update_changes_counter$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER unleash_update_stat_environment_changes
|
||||
AFTER INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION unleash_update_stat_environment_changes_counter();
|
||||
`, cb);
|
||||
};
|
||||
|
||||
exports.down = function(db, cb) {
|
||||
db.runSql(`
|
||||
DROP TRIGGER IF EXISTS unleash_update_stat_environment_changes ON events;
|
||||
DROP FUNCTION IF EXISTS unleash_update_stat_environment_changes_counter;
|
||||
DROP TABLE IF EXISTS stat_environment_updates;
|
||||
`, cb);
|
||||
};
|
Loading…
Reference in New Issue
Block a user