diff --git a/frontend/src/openapi/models/index.ts b/frontend/src/openapi/models/index.ts index 5bf2cd4518..7dba6696a5 100644 --- a/frontend/src/openapi/models/index.ts +++ b/frontend/src/openapi/models/index.ts @@ -964,6 +964,7 @@ export * from './instanceAdminStatsSchemaActiveUsers.js'; export * from './instanceAdminStatsSchemaApiTokens.js'; export * from './instanceAdminStatsSchemaClientAppsItem.js'; export * from './instanceAdminStatsSchemaClientAppsItemRange.js'; +export * from './instanceAdminStatsSchemaEdgeInstances.js'; export * from './instanceAdminStatsSchemaPreviousDayMetricsBucketsCount.js'; export * from './instanceAdminStatsSchemaProductionChanges.js'; export * from './instanceInsightsSchema.js'; diff --git a/frontend/src/openapi/models/instanceAdminStatsSchema.ts b/frontend/src/openapi/models/instanceAdminStatsSchema.ts index d19b3028b2..97f638ebf5 100644 --- a/frontend/src/openapi/models/instanceAdminStatsSchema.ts +++ b/frontend/src/openapi/models/instanceAdminStatsSchema.ts @@ -6,6 +6,7 @@ import type { InstanceAdminStatsSchemaActiveUsers } from './instanceAdminStatsSchemaActiveUsers.js'; import type { InstanceAdminStatsSchemaApiTokens } from './instanceAdminStatsSchemaApiTokens.js'; import type { InstanceAdminStatsSchemaClientAppsItem } from './instanceAdminStatsSchemaClientAppsItem.js'; +import type { InstanceAdminStatsSchemaEdgeInstances } from './instanceAdminStatsSchemaEdgeInstances.js'; import type { InstanceAdminStatsSchemaPreviousDayMetricsBucketsCount } from './instanceAdminStatsSchemaPreviousDayMetricsBucketsCount.js'; import type { InstanceAdminStatsSchemaProductionChanges } from './instanceAdminStatsSchemaProductionChanges.js'; @@ -24,6 +25,8 @@ export interface InstanceAdminStatsSchema { * @minimum 0 */ contextFields?: number; + /** The rounded up average number of edge instances in the last month and month before last */ + edgeInstances?: InstanceAdminStatsSchemaEdgeInstances; /** * The number of environments defined in this instance * @minimum 0 diff --git a/frontend/src/openapi/models/instanceAdminStatsSchemaEdgeInstances.ts b/frontend/src/openapi/models/instanceAdminStatsSchemaEdgeInstances.ts new file mode 100644 index 0000000000..1c027c9077 --- /dev/null +++ b/frontend/src/openapi/models/instanceAdminStatsSchemaEdgeInstances.ts @@ -0,0 +1,21 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +/** + * The rounded up average number of edge instances in the last month and month before last + */ +export type InstanceAdminStatsSchemaEdgeInstances = { + /** + * The rounded up average number of edge instances in the last month + * @minimum 0 + */ + lastMonth?: number; + /** + * The rounded up average number of edge instances in the month before last + * @minimum 0 + */ + monthBeforeLast?: number; +}; diff --git a/src/lib/features/instance-stats/createInstanceStatsService.ts b/src/lib/features/instance-stats/createInstanceStatsService.ts index 048fb1a45a..10a8add32e 100644 --- a/src/lib/features/instance-stats/createInstanceStatsService.ts +++ b/src/lib/features/instance-stats/createInstanceStatsService.ts @@ -50,6 +50,10 @@ import { } from './getLicensedUsers.js'; import { ReleasePlanStore } from '../release-plans/release-plan-store.js'; import { ReleasePlanTemplateStore } from '../release-plans/release-plan-template-store.js'; +import { + createFakeGetEdgeInstances, + createGetEdgeInstances, +} from './getEdgeInstances.js'; export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => { const { eventBus, getLogger, flagResolver } = config; @@ -134,6 +138,7 @@ export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => { const getActiveUsers = createGetActiveUsers(db); const getProductionChanges = createGetProductionChanges(db); const getLicencedUsers = createGetLicensedUsers(db); + const getEdgeInstances = createGetEdgeInstances(db); const versionService = new VersionService(versionServiceStores, config); const instanceStatsService = new InstanceStatsService( @@ -143,6 +148,7 @@ export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => { getActiveUsers, getProductionChanges, getLicencedUsers, + getEdgeInstances, ); return instanceStatsService; @@ -199,6 +205,7 @@ export const createFakeInstanceStatsService = (config: IUnleashConfig) => { const getActiveUsers = createFakeGetActiveUsers(); const getLicensedUsers = createFakeGetLicensedUsers(); const getProductionChanges = createFakeGetProductionChanges(); + const getEdgeInstances = createFakeGetEdgeInstances(); const versionService = new VersionService(versionServiceStores, config); const instanceStatsService = new InstanceStatsService( @@ -208,6 +215,7 @@ export const createFakeInstanceStatsService = (config: IUnleashConfig) => { getActiveUsers, getProductionChanges, getLicensedUsers, + getEdgeInstances, ); return instanceStatsService; diff --git a/src/lib/features/instance-stats/getEdgeInstances.e2e.test.ts b/src/lib/features/instance-stats/getEdgeInstances.e2e.test.ts new file mode 100644 index 0000000000..ca45ded3ae --- /dev/null +++ b/src/lib/features/instance-stats/getEdgeInstances.e2e.test.ts @@ -0,0 +1,132 @@ +import { + createGetEdgeInstances, + type GetEdgeInstances, +} from './getEdgeInstances.js'; +import dbInit, { + type ITestDb, +} from '../../../test/e2e/helpers/database-init.js'; +import getLogger from '../../../test/fixtures/no-logger.js'; + +let db: ITestDb; +let getEdgeInstances: GetEdgeInstances; + +const TABLE = 'edge_node_presence'; + +const firstDayOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1); +const addMonths = (d: Date, n: number) => + new Date(d.getFullYear(), d.getMonth() + n, 1); + +const monthWindows = () => { + const now = new Date(); + const thisMonthStart = firstDayOfMonth(now); + const lastMonthStart = addMonths(thisMonthStart, -1); + const monthBeforeLastStart = addMonths(thisMonthStart, -2); + const lastMonthEnd = thisMonthStart; + const monthBeforeLastEnd = lastMonthStart; + return { + monthBeforeLastStart, + monthBeforeLastEnd, + lastMonthStart, + lastMonthEnd, + thisMonthStart, + }; +}; + +const atMidMonth = (start: Date) => + new Date(start.getFullYear(), start.getMonth(), 15); +const atLateMonth = (start: Date) => + new Date(start.getFullYear(), start.getMonth(), 25); + +const rowsForBucket = (count: number, when: Date) => + Array.from({ length: count }, (_, i) => ({ + bucket_ts: when, + node_ephem_id: `node-${when.getTime()}-${i}`, + })); + +beforeAll(async () => { + db = await dbInit('edge_instances_e2e', getLogger); + getEdgeInstances = createGetEdgeInstances(db.rawDatabase); +}); + +afterEach(async () => { + await db.rawDatabase(TABLE).delete(); +}); + +afterAll(async () => { + await db.destroy(); +}); + +test('returns 0 for both months when no data', async () => { + await expect(getEdgeInstances()).resolves.toEqual({ + lastMonth: 0, + monthBeforeLast: 0, + }); +}); + +test('counts only last full month', async () => { + const { lastMonthStart } = monthWindows(); + const mid = atMidMonth(lastMonthStart); + const late = atLateMonth(lastMonthStart); + await db + .rawDatabase(TABLE) + .insert([...rowsForBucket(3, mid), ...rowsForBucket(7, late)]); + await expect(getEdgeInstances()).resolves.toEqual({ + lastMonth: 5, + monthBeforeLast: 0, + }); +}); + +test('counts only month before last', async () => { + const { monthBeforeLastStart } = monthWindows(); + const mid = atMidMonth(monthBeforeLastStart); + const late = atLateMonth(monthBeforeLastStart); + await db + .rawDatabase(TABLE) + .insert([...rowsForBucket(2, mid), ...rowsForBucket(5, late)]); + await expect(getEdgeInstances()).resolves.toEqual({ + lastMonth: 0, + monthBeforeLast: 4, + }); +}); + +test('separates months correctly when both have data', async () => { + const { monthBeforeLastStart, lastMonthStart } = monthWindows(); + const pMid = atMidMonth(monthBeforeLastStart); + const pLate = atLateMonth(monthBeforeLastStart); + const lMid = atMidMonth(lastMonthStart); + const lLate = atLateMonth(lastMonthStart); + + await db + .rawDatabase(TABLE) + .insert([ + ...rowsForBucket(4, pMid), + ...rowsForBucket(6, pLate), + ...rowsForBucket(3, lMid), + ...rowsForBucket(7, lLate), + ]); + + await expect(getEdgeInstances()).resolves.toEqual({ + lastMonth: 5, + monthBeforeLast: 5, + }); +}); + +test('ignores current month data', async () => { + const { thisMonthStart, lastMonthStart } = monthWindows(); + const lMid = atMidMonth(lastMonthStart); + const lLate = atLateMonth(lastMonthStart); + const tMid = atMidMonth(thisMonthStart); + + await db + .rawDatabase(TABLE) + .insert([ + ...rowsForBucket(10, tMid), + ...rowsForBucket(2, lMid), + ...rowsForBucket(4, lLate), + ]); + + await expect(getEdgeInstances()).resolves.toEqual({ + lastMonth: 3, + monthBeforeLast: 0, + }); +}); diff --git a/src/lib/features/instance-stats/getEdgeInstances.ts b/src/lib/features/instance-stats/getEdgeInstances.ts new file mode 100644 index 0000000000..ee7581c54a --- /dev/null +++ b/src/lib/features/instance-stats/getEdgeInstances.ts @@ -0,0 +1,69 @@ +import type { Db } from '../../types/index.js'; + +const TABLE = 'edge_node_presence'; + +export type GetEdgeInstances = () => Promise<{ + lastMonth: number; + monthBeforeLast: number; +}>; + +export const createGetEdgeInstances = + (db: Db): GetEdgeInstances => + async () => { + const result = await db + .with('buckets', (qb) => + qb + .from(TABLE) + .whereRaw( + "bucket_ts >= date_trunc('month', NOW()) - INTERVAL '2 months'", + ) + .groupBy('bucket_ts') + .select( + db.raw('bucket_ts'), + db.raw('COUNT(*)::int AS active_nodes'), + ), + ) + .from('buckets') + .select({ + lastMonth: db.raw(` + COALESCE( + CEIL( + AVG(active_nodes) + FILTER ( + WHERE bucket_ts >= date_trunc('month', NOW()) - INTERVAL '1 month' + AND bucket_ts < date_trunc('month', NOW()) + ) + )::int, + 0 + ) + `), + monthBeforeLast: db.raw(` + COALESCE( + CEIL( + AVG(active_nodes) + FILTER ( + WHERE bucket_ts >= date_trunc('month', NOW()) - INTERVAL '2 months' + AND bucket_ts < date_trunc('month', NOW()) - INTERVAL '1 month' + ) + )::int, + 0 + ) + `), + }) + .first(); + + return { + lastMonth: Number(result?.lastMonth ?? 0), + monthBeforeLast: Number(result?.monthBeforeLast ?? 0), + }; + }; + +export const createFakeGetEdgeInstances = + ( + edgeInstances: Awaited> = { + lastMonth: 0, + monthBeforeLast: 0, + }, + ): GetEdgeInstances => + () => + Promise.resolve(edgeInstances); 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 e3786c609a..fc0955ab62 100644 --- a/src/lib/features/instance-stats/instance-stats-service.test.ts +++ b/src/lib/features/instance-stats/instance-stats-service.test.ts @@ -14,6 +14,7 @@ import type { import { createFakeGetLicensedUsers } from './getLicensedUsers.js'; import { vi } from 'vitest'; import { DEFAULT_ENV } from '../../server-impl.js'; +import { createFakeGetEdgeInstances } from './getEdgeInstances.js'; let instanceStatsService: InstanceStatsService; let versionService: VersionService; @@ -39,6 +40,7 @@ beforeEach(() => { createFakeGetActiveUsers(), createFakeGetProductionChanges(), createFakeGetLicensedUsers(), + createFakeGetEdgeInstances(), ); const { collectAggDbMetrics } = registerPrometheusMetrics( diff --git a/src/lib/features/instance-stats/instance-stats-service.ts b/src/lib/features/instance-stats/instance-stats-service.ts index e99be95980..5790d58caf 100644 --- a/src/lib/features/instance-stats/instance-stats-service.ts +++ b/src/lib/features/instance-stats/instance-stats-service.ts @@ -33,6 +33,7 @@ import type { GetLicensedUsers } from './getLicensedUsers.js'; import type { IFeatureUsageInfo } from '../../services/version-service.js'; import type { ReleasePlanTemplateStore } from '../release-plans/release-plan-template-store.js'; import type { ReleasePlanStore } from '../release-plans/release-plan-store.js'; +import type { GetEdgeInstances } from './getEdgeInstances.js'; export type TimeRange = 'allTime' | '30d' | '7d'; @@ -74,6 +75,7 @@ export interface InstanceStats { maxConstraintValues: number; releaseTemplates?: number; releasePlans?: number; + edgeInstances?: Awaited>; } export type InstanceStatsSigned = Omit & { @@ -124,6 +126,8 @@ export class InstanceStatsService { getProductionChanges: GetProductionChanges; + getEdgeInstances: GetEdgeInstances; + private featureStrategiesReadModel: IFeatureStrategiesReadModel; private featureStrategiesStore: IFeatureStrategiesStore; @@ -185,6 +189,7 @@ export class InstanceStatsService { getActiveUsers: GetActiveUsers, getProductionChanges: GetProductionChanges, getLicencedUsers: GetLicensedUsers, + getEdgeInstances: GetEdgeInstances, ) { this.strategyStore = strategyStore; this.userStore = userStore; @@ -209,6 +214,8 @@ export class InstanceStatsService { 'getProductionChanges', getProductionChanges.bind(this), ); + this.getEdgeInstances = () => + this.memorize('getEdgeInstances', getEdgeInstances.bind(this)); this.apiTokenStore = apiTokenStore; this.clientMetricsStore = clientMetricsStoreV2; this.flagResolver = flagResolver; @@ -356,6 +363,7 @@ export class InstanceStatsService { maxConstraints, releaseTemplates, releasePlans, + edgeInstances, ] = await Promise.all([ this.getToggleCount(), this.getArchivedToggleCount(), @@ -402,6 +410,7 @@ export class InstanceStatsService { ), this.getReleaseTemplates(), this.getReleasePlans(), + this.getEdgeInstances(), ]); return { @@ -442,6 +451,7 @@ export class InstanceStatsService { maxConstraints: maxConstraints?.count ?? 0, releaseTemplates, releasePlans, + edgeInstances, }; } @@ -471,6 +481,7 @@ export class InstanceStatsService { hostedBy, releaseTemplates, releasePlans, + edgeInstances, ] = await Promise.all([ this.getToggleCount(), this.getRegisteredUsers(), @@ -496,6 +507,7 @@ export class InstanceStatsService { this.getHostedBy(), this.getReleaseTemplates(), this.getReleasePlans(), + this.getEdgeInstances(), ]); const versionInfo = await this.versionService.getVersionInfo(); @@ -533,6 +545,8 @@ export class InstanceStatsService { hostedBy, releaseTemplates, releasePlans, + edgeInstancesLastMonth: edgeInstances.lastMonth, + edgeInstancesMonthBeforeLast: edgeInstances.monthBeforeLast, }; return featureInfo; } diff --git a/src/lib/metrics.test.ts b/src/lib/metrics.test.ts index 8a255365e8..7a55af52c3 100644 --- a/src/lib/metrics.test.ts +++ b/src/lib/metrics.test.ts @@ -40,6 +40,7 @@ import dbInit, { type ITestDb } from '../test/e2e/helpers/database-init.js'; import { FeatureLifecycleStore } from './features/feature-lifecycle/feature-lifecycle-store.js'; import { FeatureLifecycleReadModel } from './features/feature-lifecycle/feature-lifecycle-read-model.js'; import { createFakeGetLicensedUsers } from './features/instance-stats/getLicensedUsers.js'; +import { createFakeGetEdgeInstances } from './features/instance-stats/getEdgeInstances.js'; const monitor = createMetricsMonitor(); const eventBus = new EventEmitter(); @@ -79,6 +80,7 @@ beforeAll(async () => { createFakeGetActiveUsers(), createFakeGetProductionChanges(), createFakeGetLicensedUsers(), + createFakeGetEdgeInstances(), ); schedulerService = new SchedulerService( diff --git a/src/lib/openapi/spec/instance-admin-stats-schema.ts b/src/lib/openapi/spec/instance-admin-stats-schema.ts index 80728fc1e5..b83e6d5ded 100644 --- a/src/lib/openapi/spec/instance-admin-stats-schema.ts +++ b/src/lib/openapi/spec/instance-admin-stats-schema.ts @@ -284,6 +284,27 @@ export const instanceAdminStatsSchema = { example: 1, description: 'The number of release plans in this instance', }, + edgeInstances: { + type: 'object', + description: + 'The rounded up average number of edge instances in the last month and month before last', + properties: { + lastMonth: { + type: 'integer', + description: + 'The rounded up average number of edge instances in the last month', + example: 10, + minimum: 0, + }, + monthBeforeLast: { + type: 'integer', + description: + 'The rounded up average number of edge instances in the month before last', + example: 12, + minimum: 0, + }, + }, + }, sum: { type: 'string', description: diff --git a/src/lib/routes/admin-api/instance-admin.ts b/src/lib/routes/admin-api/instance-admin.ts index f3d48aa84e..55aa37d13f 100644 --- a/src/lib/routes/admin-api/instance-admin.ts +++ b/src/lib/routes/admin-api/instance-admin.ts @@ -133,6 +133,10 @@ class InstanceAdminController extends Controller { maxConstraintValues: 123, releaseTemplates: 3, releasePlans: 5, + edgeInstances: { + lastMonth: 10, + monthBeforeLast: 15, + }, }; } diff --git a/src/lib/services/version-service.test.ts b/src/lib/services/version-service.test.ts index 376ea45e4a..a442b704c1 100644 --- a/src/lib/services/version-service.test.ts +++ b/src/lib/services/version-service.test.ts @@ -45,6 +45,8 @@ const fakeTelemetryData = { hostedBy: 'self-hosted', releaseTemplates: 2, releasePlans: 4, + edgeInstancesLastMonth: 0, + edgeInstancesMonthBeforeLast: 0, }; test('yields current versions', async () => { diff --git a/src/lib/services/version-service.ts b/src/lib/services/version-service.ts index 42af259ad6..2dcdf96a2e 100644 --- a/src/lib/services/version-service.ts +++ b/src/lib/services/version-service.ts @@ -54,6 +54,8 @@ export interface IFeatureUsageInfo { hostedBy: string; releaseTemplates: number; releasePlans: number; + edgeInstancesLastMonth?: number; + edgeInstancesMonthBeforeLast?: number; } export default class VersionService {