diff --git a/frontend/src/openapi/models/index.ts b/frontend/src/openapi/models/index.ts index d7341ba249..5d33d8bab9 100644 --- a/frontend/src/openapi/models/index.ts +++ b/frontend/src/openapi/models/index.ts @@ -967,7 +967,7 @@ export * from './instanceAdminStatsSchemaActiveUsers.js'; export * from './instanceAdminStatsSchemaApiTokens.js'; export * from './instanceAdminStatsSchemaClientAppsItem.js'; export * from './instanceAdminStatsSchemaClientAppsItemRange.js'; -export * from './instanceAdminStatsSchemaEdgeInstances.js'; +export * from './instanceAdminStatsSchemaEdgeInstanceUsage.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 97f638ebf5..5041e862fb 100644 --- a/frontend/src/openapi/models/instanceAdminStatsSchema.ts +++ b/frontend/src/openapi/models/instanceAdminStatsSchema.ts @@ -6,7 +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 { InstanceAdminStatsSchemaEdgeInstanceUsage } from './instanceAdminStatsSchemaEdgeInstanceUsage.js'; import type { InstanceAdminStatsSchemaPreviousDayMetricsBucketsCount } from './instanceAdminStatsSchemaPreviousDayMetricsBucketsCount.js'; import type { InstanceAdminStatsSchemaProductionChanges } from './instanceAdminStatsSchemaProductionChanges.js'; @@ -25,8 +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 average number of edge instances, per month, in the last 12 months, rounded to 3 decimal places */ + edgeInstanceUsage?: InstanceAdminStatsSchemaEdgeInstanceUsage; /** * The number of environments defined in this instance * @minimum 0 diff --git a/frontend/src/openapi/models/instanceAdminStatsSchemaEdgeInstanceUsage.ts b/frontend/src/openapi/models/instanceAdminStatsSchemaEdgeInstanceUsage.ts new file mode 100644 index 0000000000..7fe4819fca --- /dev/null +++ b/frontend/src/openapi/models/instanceAdminStatsSchemaEdgeInstanceUsage.ts @@ -0,0 +1,12 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +/** + * The average number of edge instances, per month, in the last 12 months, rounded to 3 decimal places + */ +export type InstanceAdminStatsSchemaEdgeInstanceUsage = { + [key: string]: number; +}; diff --git a/frontend/src/openapi/models/instanceAdminStatsSchemaEdgeInstances.ts b/frontend/src/openapi/models/instanceAdminStatsSchemaEdgeInstances.ts deleted file mode 100644 index 1c027c9077..0000000000 --- a/frontend/src/openapi/models/instanceAdminStatsSchemaEdgeInstances.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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/getEdgeInstances.e2e.test.ts b/src/lib/features/instance-stats/getEdgeInstances.e2e.test.ts index ca45ded3ae..d60c43d2ac 100644 --- a/src/lib/features/instance-stats/getEdgeInstances.e2e.test.ts +++ b/src/lib/features/instance-stats/getEdgeInstances.e2e.test.ts @@ -12,39 +12,105 @@ 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 getMonthRange = async (raw: ITestDb['rawDatabase'], offset: number) => { + const { rows } = await raw.raw( + ` + SELECT + (date_trunc('month', NOW()) - (? * INTERVAL '1 month'))::timestamptz AS start_ts, + (date_trunc('month', NOW()) - ((? - 1) * INTERVAL '1 month'))::timestamptz AS end_ts, + to_char((date_trunc('month', NOW()) - (? * INTERVAL '1 month')), 'YYYY-MM') AS key + `, + [offset, offset, offset], + ); + return rows[0] as { start_ts: string; end_ts: string; key: string }; }; -const atMidMonth = (start: Date) => - new Date(start.getFullYear(), start.getMonth(), 15); -const atLateMonth = (start: Date) => - new Date(start.getFullYear(), start.getMonth(), 25); +const expectedForWindow = async ( + raw: ITestDb['rawDatabase'], + startIso: string, + endIso: string, +) => { + const { rows } = await raw.raw( + ` + WITH mw AS ( + SELECT ?::timestamptz AS start_ts, ?::timestamptz AS end_ts + ), + buckets AS ( + SELECT bucket_ts, COUNT(DISTINCT node_ephem_id)::int AS active_nodes + FROM ${TABLE} + CROSS JOIN mw + WHERE bucket_ts >= mw.start_ts AND bucket_ts < mw.end_ts + GROUP BY bucket_ts + ), + totals AS ( + SELECT COALESCE(SUM(active_nodes), 0) AS total FROM buckets + ) + SELECT COALESCE( + ROUND( + (totals.total::numeric) + / NULLIF(FLOOR(EXTRACT(EPOCH FROM (mw.end_ts - mw.start_ts)) / 300)::int, 0), + 3), + 0 + ) AS val + FROM totals CROSS JOIN mw + `, + [startIso, endIso], + ); + return Number(rows[0].val); +}; -const rowsForBucket = (count: number, when: Date) => - Array.from({ length: count }, (_, i) => ({ - bucket_ts: when, - node_ephem_id: `node-${when.getTime()}-${i}`, - })); +const countRowsInWindow = async ( + raw: ITestDb['rawDatabase'], + startIso: string, + endIso: string, +) => { + const { rows } = await raw.raw( + ` + SELECT COUNT(*)::int AS c + FROM ${TABLE} + WHERE bucket_ts >= ?::timestamptz AND bucket_ts < ?::timestamptz + `, + [startIso, endIso], + ); + return Number(rows[0].c); +}; + +const insertSpreadAcrossMonth = async ( + raw: ITestDb['rawDatabase'], + startIso: string, + endIso: string, + everyNth: number, + nodesPerBucket: number, +) => { + await raw.raw( + ` + WITH series AS ( + SELECT generate_series( + ?::timestamptz, + (?::timestamptz - INTERVAL '5 minutes'), + INTERVAL '5 minutes' + ) AS ts + ), + picked AS ( + SELECT ts + FROM series + WHERE (EXTRACT(EPOCH FROM ts) / 300)::bigint % ? = 0 + ) + INSERT INTO ${TABLE} (bucket_ts, node_ephem_id) + SELECT + p.ts, + 'node-' || to_char(p.ts AT TIME ZONE 'UTC', 'YYYYMMDDHH24MI') || '-' || i::text + FROM picked p + CROSS JOIN generate_series(1, ?) AS i + ON CONFLICT (bucket_ts, node_ephem_id) DO NOTHING + `, + [startIso, endIso, everyNth, nodesPerBucket], + ); +}; beforeAll(async () => { - db = await dbInit('edge_instances_e2e', getLogger); + db = await dbInit('edge_instances_series_e2e', getLogger); + await db.rawDatabase.raw(`SET TIME ZONE 'UTC'`); getEdgeInstances = createGetEdgeInstances(db.rawDatabase); }); @@ -56,77 +122,99 @@ 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('returns 12 months with zeros when no data and keys match exact last-12 range', async () => { + const res = await getEdgeInstances(); + const keys = Object.keys(res); + expect(keys.length).toBe(12); + for (let i = 1; i <= 12; i++) { + const { key } = await getMonthRange(db.rawDatabase, i); + expect(keys).toContain(key); + expect(res[key]).toBe(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('computes average for last month and guarantees data actually inserted', async () => { + const { start_ts, end_ts, key } = await getMonthRange(db.rawDatabase, 1); + await insertSpreadAcrossMonth(db.rawDatabase, start_ts, end_ts, 2, 3); + const inserted = await countRowsInWindow(db.rawDatabase, start_ts, end_ts); + expect(inserted).toBeGreaterThan(0); + const expected = await expectedForWindow(db.rawDatabase, start_ts, end_ts); + expect(expected).toBeGreaterThan(0); + const res = await getEdgeInstances(); + expect(res[key]).toBe(expected); }); -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 last two months with different loads and at least one non-zero in series', async () => { + const m1 = await getMonthRange(db.rawDatabase, 1); + const m2 = await getMonthRange(db.rawDatabase, 2); + await insertSpreadAcrossMonth(db.rawDatabase, m2.start_ts, m2.end_ts, 4, 9); + await insertSpreadAcrossMonth(db.rawDatabase, m1.start_ts, m1.end_ts, 3, 4); + const insertedM1 = await countRowsInWindow( + db.rawDatabase, + m1.start_ts, + m1.end_ts, + ); + const insertedM2 = await countRowsInWindow( + db.rawDatabase, + m2.start_ts, + m2.end_ts, + ); + expect(insertedM1).toBeGreaterThan(0); + expect(insertedM2).toBeGreaterThan(0); + const expectedM1 = await expectedForWindow( + db.rawDatabase, + m1.start_ts, + m1.end_ts, + ); + const expectedM2 = await expectedForWindow( + db.rawDatabase, + m2.start_ts, + m2.end_ts, + ); + const res = await getEdgeInstances(); + expect(res[m1.key]).toBe(expectedM1); + expect(res[m2.key]).toBe(expectedM2); + const nonZero = Object.values(res).filter((v) => v > 0).length; + expect(nonZero).toBeGreaterThan(0); }); -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, - }); +test('ignores current month data; verifies current-month insert happened but key is absent and previous month has non-zero', async () => { + const current = await getMonthRange(db.rawDatabase, 0); + const m1 = await getMonthRange(db.rawDatabase, 1); + const m2 = await getMonthRange(db.rawDatabase, 2); + await insertSpreadAcrossMonth( + db.rawDatabase, + current.start_ts, + current.end_ts, + 1, + 12, + ); + await insertSpreadAcrossMonth( + db.rawDatabase, + m1.start_ts, + m1.end_ts, + 5, + 11, + ); + const currentInserted = await countRowsInWindow( + db.rawDatabase, + current.start_ts, + current.end_ts, + ); + expect(currentInserted).toBeGreaterThan(0); + const expectedM1 = await expectedForWindow( + db.rawDatabase, + m1.start_ts, + m1.end_ts, + ); + const expectedM2 = await expectedForWindow( + db.rawDatabase, + m2.start_ts, + m2.end_ts, + ); + const res = await getEdgeInstances(); + expect(Object.keys(res)).not.toContain(current.key); + expect(expectedM1).toBeGreaterThan(0); + expect(res[m1.key]).toBe(expectedM1); + expect(res[m2.key]).toBe(expectedM2); }); diff --git a/src/lib/features/instance-stats/getEdgeInstances.ts b/src/lib/features/instance-stats/getEdgeInstances.ts index ee7581c54a..63474ded17 100644 --- a/src/lib/features/instance-stats/getEdgeInstances.ts +++ b/src/lib/features/instance-stats/getEdgeInstances.ts @@ -2,68 +2,83 @@ import type { Db } from '../../types/index.js'; const TABLE = 'edge_node_presence'; -export type GetEdgeInstances = () => Promise<{ - lastMonth: number; - monthBeforeLast: number; -}>; +export type EdgeInstanceUsage = Record; +export type GetEdgeInstances = () => Promise; export const createGetEdgeInstances = (db: Db): GetEdgeInstances => async () => { - const result = await db + const rows = await db + .with('months', (qb) => + qb.fromRaw('generate_series(1, 12) AS gs').select( + db.raw( + "(date_trunc('month', NOW()) - (gs * INTERVAL '1 month'))::timestamptz AS mon_start", + ), + db.raw( + "(date_trunc('month', NOW()) - ((gs - 1) * INTERVAL '1 month'))::timestamptz AS mon_end", + ), + db.raw( + "to_char((date_trunc('month', NOW()) - (gs * INTERVAL '1 month')),'YYYY-MM') AS key", + ), + db.raw(` + FLOOR(EXTRACT(EPOCH FROM ( + (date_trunc('month', NOW()) - ((gs - 1) * INTERVAL '1 month')) + - (date_trunc('month', NOW()) - (gs * INTERVAL '1 month')) + )) / 300)::int AS expected + `), + ), + ) + .with('range', (qb) => + qb + .from('months') + .select( + db.raw('MIN(mon_start) AS min_start'), + db.raw('MAX(mon_end) AS max_end'), + ), + ) .with('buckets', (qb) => qb .from(TABLE) + .joinRaw('CROSS JOIN range') .whereRaw( - "bucket_ts >= date_trunc('month', NOW()) - INTERVAL '2 months'", + 'bucket_ts >= range.min_start AND bucket_ts < range.max_end', ) .groupBy('bucket_ts') .select( db.raw('bucket_ts'), - db.raw('COUNT(*)::int AS active_nodes'), + db.raw( + 'COUNT(DISTINCT node_ephem_id)::int AS active_nodes', + ), ), ) - .from('buckets') - .select({ - lastMonth: db.raw(` + .from('months as m') + .joinRaw( + 'LEFT JOIN buckets b ON b.bucket_ts >= m.mon_start AND b.bucket_ts < m.mon_end', + ) + .groupBy('m.key', 'm.expected') + .orderBy('m.key', 'desc') + .select( + db.raw('m.key'), + 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, + ROUND((SUM(b.active_nodes)::numeric) / NULLIF(m.expected, 0), 3), 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(); + ) AS value + `, + ), + ); - return { - lastMonth: Number(result?.lastMonth ?? 0), - monthBeforeLast: Number(result?.monthBeforeLast ?? 0), - }; + const series: EdgeInstanceUsage = {}; + for (const r of rows as Array<{ key: string; value: number }>) { + series[r.key] = Number(r.value ?? 0); + } + return series; }; export const createFakeGetEdgeInstances = ( - edgeInstances: Awaited> = { - lastMonth: 0, - monthBeforeLast: 0, - }, + edgeInstances: Awaited> = {}, ): GetEdgeInstances => () => Promise.resolve(edgeInstances); diff --git a/src/lib/features/instance-stats/instance-stats-service.ts b/src/lib/features/instance-stats/instance-stats-service.ts index 5790d58caf..7275e69635 100644 --- a/src/lib/features/instance-stats/instance-stats-service.ts +++ b/src/lib/features/instance-stats/instance-stats-service.ts @@ -75,7 +75,7 @@ export interface InstanceStats { maxConstraintValues: number; releaseTemplates?: number; releasePlans?: number; - edgeInstances?: Awaited>; + edgeInstanceUsage?: Awaited>; } export type InstanceStatsSigned = Omit & { @@ -363,7 +363,7 @@ export class InstanceStatsService { maxConstraints, releaseTemplates, releasePlans, - edgeInstances, + edgeInstanceUsage, ] = await Promise.all([ this.getToggleCount(), this.getArchivedToggleCount(), @@ -451,7 +451,7 @@ export class InstanceStatsService { maxConstraints: maxConstraints?.count ?? 0, releaseTemplates, releasePlans, - edgeInstances, + edgeInstanceUsage, }; } @@ -481,7 +481,7 @@ export class InstanceStatsService { hostedBy, releaseTemplates, releasePlans, - edgeInstances, + edgeInstanceUsage, ] = await Promise.all([ this.getToggleCount(), this.getRegisteredUsers(), @@ -545,8 +545,7 @@ export class InstanceStatsService { hostedBy, releaseTemplates, releasePlans, - edgeInstancesLastMonth: edgeInstances.lastMonth, - edgeInstancesMonthBeforeLast: edgeInstances.monthBeforeLast, + edgeInstanceUsage, }; return featureInfo; } diff --git a/src/lib/openapi/spec/instance-admin-stats-schema.ts b/src/lib/openapi/spec/instance-admin-stats-schema.ts index b83e6d5ded..f776bf4944 100644 --- a/src/lib/openapi/spec/instance-admin-stats-schema.ts +++ b/src/lib/openapi/spec/instance-admin-stats-schema.ts @@ -284,25 +284,18 @@ export const instanceAdminStatsSchema = { example: 1, description: 'The number of release plans in this instance', }, - edgeInstances: { + edgeInstanceUsage: { 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, - }, + 'The average number of edge instances, per month, in the last 12 months, rounded to 3 decimal places', + additionalProperties: { + type: 'number', + minimum: 0, + }, + example: { + '2025-09': 2.25, + '2025-08': 1.75, + '2024-10': 0.45, }, }, sum: { diff --git a/src/lib/routes/admin-api/instance-admin.ts b/src/lib/routes/admin-api/instance-admin.ts index 55aa37d13f..9fa9d2e094 100644 --- a/src/lib/routes/admin-api/instance-admin.ts +++ b/src/lib/routes/admin-api/instance-admin.ts @@ -133,9 +133,10 @@ class InstanceAdminController extends Controller { maxConstraintValues: 123, releaseTemplates: 3, releasePlans: 5, - edgeInstances: { - lastMonth: 10, - monthBeforeLast: 15, + edgeInstanceUsage: { + '2022-06': 2.345, + '2022-07': 2.567, + '2022-08': 2.789, }, }; } diff --git a/src/lib/services/version-service.test.ts b/src/lib/services/version-service.test.ts index a442b704c1..8f96e15d4f 100644 --- a/src/lib/services/version-service.test.ts +++ b/src/lib/services/version-service.test.ts @@ -45,8 +45,7 @@ const fakeTelemetryData = { hostedBy: 'self-hosted', releaseTemplates: 2, releasePlans: 4, - edgeInstancesLastMonth: 0, - edgeInstancesMonthBeforeLast: 0, + edgeInstanceUsage: {}, }; test('yields current versions', async () => { diff --git a/src/lib/services/version-service.ts b/src/lib/services/version-service.ts index 2dcdf96a2e..639d876018 100644 --- a/src/lib/services/version-service.ts +++ b/src/lib/services/version-service.ts @@ -4,6 +4,7 @@ import type { IUnleashConfig } from '../types/option.js'; import version from '../util/version.js'; import type { Logger } from '../logger.js'; import type { ISettingStore } from '../types/stores/settings-store.js'; +import type { EdgeInstanceUsage } from '../features/instance-stats/getEdgeInstances.js'; export interface IVersionInfo { oss: string; @@ -54,8 +55,7 @@ export interface IFeatureUsageInfo { hostedBy: string; releaseTemplates: number; releasePlans: number; - edgeInstancesLastMonth?: number; - edgeInstancesMonthBeforeLast?: number; + edgeInstanceUsage?: EdgeInstanceUsage; } export default class VersionService {