diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts index 37c303be1b..524d016e32 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -146,9 +146,7 @@ class EventStore implements IEventStore { async batchStore(events: IBaseEvent[]): Promise { 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}`); } diff --git a/src/lib/features/instance-stats/getProductionChanges.e2e.test.ts b/src/lib/features/instance-stats/getProductionChanges.e2e.test.ts new file mode 100644 index 0000000000..5d33efee60 --- /dev/null +++ b/src/lib/features/instance-stats/getProductionChanges.e2e.test.ts @@ -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, + }); +}); diff --git a/src/lib/features/instance-stats/getProductionChanges.ts b/src/lib/features/instance-stats/getProductionChanges.ts new file mode 100644 index 0000000000..ee6116e304 --- /dev/null +++ b/src/lib/features/instance-stats/getProductionChanges.ts @@ -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> = { + last30: 0, + last60: 0, + last90: 0, + }, + ): GetProductionChanges => + () => + Promise.resolve(changesInProduction); 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 69532cc0e2..0b9d9d6491 100644 --- a/src/lib/features/instance-stats/instance-stats-service.test.ts +++ b/src/lib/features/instance-stats/instance-stats-service.test.ts @@ -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'); diff --git a/src/lib/features/instance-stats/instance-stats-service.ts b/src/lib/features/instance-stats/instance-stats-service.ts index 9cce8af42c..532466a0bc 100644 --- a/src/lib/features/instance-stats/instance-stats-service.ts +++ b/src/lib/features/instance-stats/instance-stats-service.ts @@ -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>; + productionChanges: Awaited>; } export type InstanceStatsSigned = Omit & { @@ -88,6 +90,8 @@ export class InstanceStatsService { private getActiveUsers: GetActiveUsers; + private getProductionChanges: GetProductionChanges; + constructor( { featureToggleStore, @@ -120,6 +124,7 @@ export class InstanceStatsService { { getLogger }: Pick, 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 { @@ -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, }; } diff --git a/src/lib/metrics.test.ts b/src/lib/metrics.test.ts index 1d1daeede2..af09315a71 100644 --- a/src/lib/metrics.test.ts +++ b/src/lib/metrics.test.ts @@ -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 = { diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index ea871c09ac..7c39636501 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -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 diff --git a/src/lib/openapi/spec/instance-admin-stats-schema.ts b/src/lib/openapi/spec/instance-admin-stats-schema.ts index 5928acb322..4105051eb6 100644 --- a/src/lib/openapi/spec/instance-admin-stats-schema.ts +++ b/src/lib/openapi/spec/instance-admin-stats-schema.ts @@ -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', diff --git a/src/lib/routes/admin-api/instance-admin.ts b/src/lib/routes/admin-api/instance-admin.ts index 9b8a343e75..374cf9f132 100644 --- a/src/lib/routes/admin-api/instance-admin.ts +++ b/src/lib/routes/admin-api/instance-admin.ts @@ -113,6 +113,11 @@ class InstanceAdminController extends Controller { last30: 10, last7: 5, }, + productionChanges: { + last30: 100, + last60: 200, + last90: 200, + }, }; } diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 9744a99bda..bf67b8efdf 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -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); diff --git a/src/lib/services/version-service.test.ts b/src/lib/services/version-service.test.ts index 7e0537fcbe..ede3dae606 100644 --- a/src/lib/services/version-service.test.ts +++ b/src/lib/services/version-service.test.ts @@ -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(); diff --git a/src/lib/services/version-service.ts b/src/lib/services/version-service.ts index 3b9f6782c6..fc6e336608 100644 --- a/src/lib/services/version-service.ts +++ b/src/lib/services/version-service.ts @@ -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 { const settings = await this.settingStore.get( 'unleash.enterprise.auth.oidc', diff --git a/src/migrations/20231004120900-create-changes-stats-table-and-trigger.js b/src/migrations/20231004120900-create-changes-stats-table-and-trigger.js new file mode 100644 index 0000000000..72846937b1 --- /dev/null +++ b/src/migrations/20231004120900-create-changes-stats-table-and-trigger.js @@ -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); +};