1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

chore: add edge instances to instance stats (#10839)

https://linear.app/unleash/issue/2-3979/add-edge-instances-to-self-reported-instance-stats

Adds edge instances to instance stats.
This commit is contained in:
Nuno Góis 2025-10-22 16:40:43 +01:00 committed by GitHub
parent 09fcbe3d49
commit 8ba35507cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 281 additions and 0 deletions

View File

@ -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';

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ReturnType<GetEdgeInstances>> = {
lastMonth: 0,
monthBeforeLast: 0,
},
): GetEdgeInstances =>
() =>
Promise.resolve(edgeInstances);

View File

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

View File

@ -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<ReturnType<GetEdgeInstances>>;
}
export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & {
@ -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;
}

View File

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

View File

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

View File

@ -133,6 +133,10 @@ class InstanceAdminController extends Controller {
maxConstraintValues: 123,
releaseTemplates: 3,
releasePlans: 5,
edgeInstances: {
lastMonth: 10,
monthBeforeLast: 15,
},
};
}

View File

@ -45,6 +45,8 @@ const fakeTelemetryData = {
hostedBy: 'self-hosted',
releaseTemplates: 2,
releasePlans: 4,
edgeInstancesLastMonth: 0,
edgeInstancesMonthBeforeLast: 0,
};
test('yields current versions', async () => {

View File

@ -54,6 +54,8 @@ export interface IFeatureUsageInfo {
hostedBy: string;
releaseTemplates: number;
releasePlans: number;
edgeInstancesLastMonth?: number;
edgeInstancesMonthBeforeLast?: number;
}
export default class VersionService {