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

fix: enterprise edge stats should take into account full month (#10898)

https://linear.app/unleash/issue/2-3993/fix-enterprise-edge-stats

Fixes Enterprise Edge stats to correctly reflect the average across the
whole month.

Now returns a rounded average with 3 decimal places.

Also includes the average of the last 12 months.
This commit is contained in:
Nuno Góis 2025-11-03 13:45:38 +00:00 committed by GitHub
parent bbff52eb7b
commit 699f9e6ce2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 277 additions and 191 deletions

View File

@ -967,7 +967,7 @@ export * from './instanceAdminStatsSchemaActiveUsers.js';
export * from './instanceAdminStatsSchemaApiTokens.js'; export * from './instanceAdminStatsSchemaApiTokens.js';
export * from './instanceAdminStatsSchemaClientAppsItem.js'; export * from './instanceAdminStatsSchemaClientAppsItem.js';
export * from './instanceAdminStatsSchemaClientAppsItemRange.js'; export * from './instanceAdminStatsSchemaClientAppsItemRange.js';
export * from './instanceAdminStatsSchemaEdgeInstances.js'; export * from './instanceAdminStatsSchemaEdgeInstanceUsage.js';
export * from './instanceAdminStatsSchemaPreviousDayMetricsBucketsCount.js'; export * from './instanceAdminStatsSchemaPreviousDayMetricsBucketsCount.js';
export * from './instanceAdminStatsSchemaProductionChanges.js'; export * from './instanceAdminStatsSchemaProductionChanges.js';
export * from './instanceInsightsSchema.js'; export * from './instanceInsightsSchema.js';

View File

@ -6,7 +6,7 @@
import type { InstanceAdminStatsSchemaActiveUsers } from './instanceAdminStatsSchemaActiveUsers.js'; import type { InstanceAdminStatsSchemaActiveUsers } from './instanceAdminStatsSchemaActiveUsers.js';
import type { InstanceAdminStatsSchemaApiTokens } from './instanceAdminStatsSchemaApiTokens.js'; import type { InstanceAdminStatsSchemaApiTokens } from './instanceAdminStatsSchemaApiTokens.js';
import type { InstanceAdminStatsSchemaClientAppsItem } from './instanceAdminStatsSchemaClientAppsItem.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 { InstanceAdminStatsSchemaPreviousDayMetricsBucketsCount } from './instanceAdminStatsSchemaPreviousDayMetricsBucketsCount.js';
import type { InstanceAdminStatsSchemaProductionChanges } from './instanceAdminStatsSchemaProductionChanges.js'; import type { InstanceAdminStatsSchemaProductionChanges } from './instanceAdminStatsSchemaProductionChanges.js';
@ -25,8 +25,8 @@ export interface InstanceAdminStatsSchema {
* @minimum 0 * @minimum 0
*/ */
contextFields?: number; contextFields?: number;
/** The rounded up average number of edge instances in the last month and month before last */ /** The average number of edge instances, per month, in the last 12 months, rounded to 3 decimal places */
edgeInstances?: InstanceAdminStatsSchemaEdgeInstances; edgeInstanceUsage?: InstanceAdminStatsSchemaEdgeInstanceUsage;
/** /**
* The number of environments defined in this instance * The number of environments defined in this instance
* @minimum 0 * @minimum 0

View File

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

View File

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

View File

@ -12,39 +12,105 @@ let getEdgeInstances: GetEdgeInstances;
const TABLE = 'edge_node_presence'; const TABLE = 'edge_node_presence';
const firstDayOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1); const getMonthRange = async (raw: ITestDb['rawDatabase'], offset: number) => {
const addMonths = (d: Date, n: number) => const { rows } = await raw.raw(
new Date(d.getFullYear(), d.getMonth() + n, 1); `
SELECT
const monthWindows = () => { (date_trunc('month', NOW()) - (? * INTERVAL '1 month'))::timestamptz AS start_ts,
const now = new Date(); (date_trunc('month', NOW()) - ((? - 1) * INTERVAL '1 month'))::timestamptz AS end_ts,
const thisMonthStart = firstDayOfMonth(now); to_char((date_trunc('month', NOW()) - (? * INTERVAL '1 month')), 'YYYY-MM') AS key
const lastMonthStart = addMonths(thisMonthStart, -1); `,
const monthBeforeLastStart = addMonths(thisMonthStart, -2); [offset, offset, offset],
const lastMonthEnd = thisMonthStart; );
const monthBeforeLastEnd = lastMonthStart; return rows[0] as { start_ts: string; end_ts: string; key: string };
return {
monthBeforeLastStart,
monthBeforeLastEnd,
lastMonthStart,
lastMonthEnd,
thisMonthStart,
};
}; };
const atMidMonth = (start: Date) => const expectedForWindow = async (
new Date(start.getFullYear(), start.getMonth(), 15); raw: ITestDb['rawDatabase'],
const atLateMonth = (start: Date) => startIso: string,
new Date(start.getFullYear(), start.getMonth(), 25); 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) => const countRowsInWindow = async (
Array.from({ length: count }, (_, i) => ({ raw: ITestDb['rawDatabase'],
bucket_ts: when, startIso: string,
node_ephem_id: `node-${when.getTime()}-${i}`, 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 () => { 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); getEdgeInstances = createGetEdgeInstances(db.rawDatabase);
}); });
@ -56,77 +122,99 @@ afterAll(async () => {
await db.destroy(); await db.destroy();
}); });
test('returns 0 for both months when no data', async () => { test('returns 12 months with zeros when no data and keys match exact last-12 range', async () => {
await expect(getEdgeInstances()).resolves.toEqual({ const res = await getEdgeInstances();
lastMonth: 0, const keys = Object.keys(res);
monthBeforeLast: 0, 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 () => { test('computes average for last month and guarantees data actually inserted', async () => {
const { lastMonthStart } = monthWindows(); const { start_ts, end_ts, key } = await getMonthRange(db.rawDatabase, 1);
const mid = atMidMonth(lastMonthStart); await insertSpreadAcrossMonth(db.rawDatabase, start_ts, end_ts, 2, 3);
const late = atLateMonth(lastMonthStart); const inserted = await countRowsInWindow(db.rawDatabase, start_ts, end_ts);
await db expect(inserted).toBeGreaterThan(0);
.rawDatabase(TABLE) const expected = await expectedForWindow(db.rawDatabase, start_ts, end_ts);
.insert([...rowsForBucket(3, mid), ...rowsForBucket(7, late)]); expect(expected).toBeGreaterThan(0);
await expect(getEdgeInstances()).resolves.toEqual({ const res = await getEdgeInstances();
lastMonth: 5, expect(res[key]).toBe(expected);
monthBeforeLast: 0,
});
}); });
test('counts only month before last', async () => { test('separates last two months with different loads and at least one non-zero in series', async () => {
const { monthBeforeLastStart } = monthWindows(); const m1 = await getMonthRange(db.rawDatabase, 1);
const mid = atMidMonth(monthBeforeLastStart); const m2 = await getMonthRange(db.rawDatabase, 2);
const late = atLateMonth(monthBeforeLastStart); await insertSpreadAcrossMonth(db.rawDatabase, m2.start_ts, m2.end_ts, 4, 9);
await db await insertSpreadAcrossMonth(db.rawDatabase, m1.start_ts, m1.end_ts, 3, 4);
.rawDatabase(TABLE) const insertedM1 = await countRowsInWindow(
.insert([...rowsForBucket(2, mid), ...rowsForBucket(5, late)]); db.rawDatabase,
await expect(getEdgeInstances()).resolves.toEqual({ m1.start_ts,
lastMonth: 0, m1.end_ts,
monthBeforeLast: 4, );
}); 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 () => { test('ignores current month data; verifies current-month insert happened but key is absent and previous month has non-zero', async () => {
const { monthBeforeLastStart, lastMonthStart } = monthWindows(); const current = await getMonthRange(db.rawDatabase, 0);
const pMid = atMidMonth(monthBeforeLastStart); const m1 = await getMonthRange(db.rawDatabase, 1);
const pLate = atLateMonth(monthBeforeLastStart); const m2 = await getMonthRange(db.rawDatabase, 2);
const lMid = atMidMonth(lastMonthStart); await insertSpreadAcrossMonth(
const lLate = atLateMonth(lastMonthStart); db.rawDatabase,
current.start_ts,
await db current.end_ts,
.rawDatabase(TABLE) 1,
.insert([ 12,
...rowsForBucket(4, pMid), );
...rowsForBucket(6, pLate), await insertSpreadAcrossMonth(
...rowsForBucket(3, lMid), db.rawDatabase,
...rowsForBucket(7, lLate), m1.start_ts,
]); m1.end_ts,
5,
await expect(getEdgeInstances()).resolves.toEqual({ 11,
lastMonth: 5, );
monthBeforeLast: 5, const currentInserted = await countRowsInWindow(
}); db.rawDatabase,
}); current.start_ts,
current.end_ts,
test('ignores current month data', async () => { );
const { thisMonthStart, lastMonthStart } = monthWindows(); expect(currentInserted).toBeGreaterThan(0);
const lMid = atMidMonth(lastMonthStart); const expectedM1 = await expectedForWindow(
const lLate = atLateMonth(lastMonthStart); db.rawDatabase,
const tMid = atMidMonth(thisMonthStart); m1.start_ts,
m1.end_ts,
await db );
.rawDatabase(TABLE) const expectedM2 = await expectedForWindow(
.insert([ db.rawDatabase,
...rowsForBucket(10, tMid), m2.start_ts,
...rowsForBucket(2, lMid), m2.end_ts,
...rowsForBucket(4, lLate), );
]); const res = await getEdgeInstances();
expect(Object.keys(res)).not.toContain(current.key);
await expect(getEdgeInstances()).resolves.toEqual({ expect(expectedM1).toBeGreaterThan(0);
lastMonth: 3, expect(res[m1.key]).toBe(expectedM1);
monthBeforeLast: 0, expect(res[m2.key]).toBe(expectedM2);
});
}); });

View File

@ -2,68 +2,83 @@ import type { Db } from '../../types/index.js';
const TABLE = 'edge_node_presence'; const TABLE = 'edge_node_presence';
export type GetEdgeInstances = () => Promise<{ export type EdgeInstanceUsage = Record<string, number>;
lastMonth: number; export type GetEdgeInstances = () => Promise<EdgeInstanceUsage>;
monthBeforeLast: number;
}>;
export const createGetEdgeInstances = export const createGetEdgeInstances =
(db: Db): GetEdgeInstances => (db: Db): GetEdgeInstances =>
async () => { 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) => .with('buckets', (qb) =>
qb qb
.from(TABLE) .from(TABLE)
.joinRaw('CROSS JOIN range')
.whereRaw( .whereRaw(
"bucket_ts >= date_trunc('month', NOW()) - INTERVAL '2 months'", 'bucket_ts >= range.min_start AND bucket_ts < range.max_end',
) )
.groupBy('bucket_ts') .groupBy('bucket_ts')
.select( .select(
db.raw('bucket_ts'), db.raw('bucket_ts'),
db.raw('COUNT(*)::int AS active_nodes'), db.raw(
'COUNT(DISTINCT node_ephem_id)::int AS active_nodes',
),
), ),
) )
.from('buckets') .from('months as m')
.select({ .joinRaw(
lastMonth: db.raw(` '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( COALESCE(
CEIL( ROUND((SUM(b.active_nodes)::numeric) / NULLIF(m.expected, 0), 3),
AVG(active_nodes)
FILTER (
WHERE bucket_ts >= date_trunc('month', NOW()) - INTERVAL '1 month'
AND bucket_ts < date_trunc('month', NOW())
)
)::int,
0 0
) ) AS value
`), `,
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 { const series: EdgeInstanceUsage = {};
lastMonth: Number(result?.lastMonth ?? 0), for (const r of rows as Array<{ key: string; value: number }>) {
monthBeforeLast: Number(result?.monthBeforeLast ?? 0), series[r.key] = Number(r.value ?? 0);
}; }
return series;
}; };
export const createFakeGetEdgeInstances = export const createFakeGetEdgeInstances =
( (
edgeInstances: Awaited<ReturnType<GetEdgeInstances>> = { edgeInstances: Awaited<ReturnType<GetEdgeInstances>> = {},
lastMonth: 0,
monthBeforeLast: 0,
},
): GetEdgeInstances => ): GetEdgeInstances =>
() => () =>
Promise.resolve(edgeInstances); Promise.resolve(edgeInstances);

View File

@ -75,7 +75,7 @@ export interface InstanceStats {
maxConstraintValues: number; maxConstraintValues: number;
releaseTemplates?: number; releaseTemplates?: number;
releasePlans?: number; releasePlans?: number;
edgeInstances?: Awaited<ReturnType<GetEdgeInstances>>; edgeInstanceUsage?: Awaited<ReturnType<GetEdgeInstances>>;
} }
export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & { export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & {
@ -363,7 +363,7 @@ export class InstanceStatsService {
maxConstraints, maxConstraints,
releaseTemplates, releaseTemplates,
releasePlans, releasePlans,
edgeInstances, edgeInstanceUsage,
] = await Promise.all([ ] = await Promise.all([
this.getToggleCount(), this.getToggleCount(),
this.getArchivedToggleCount(), this.getArchivedToggleCount(),
@ -451,7 +451,7 @@ export class InstanceStatsService {
maxConstraints: maxConstraints?.count ?? 0, maxConstraints: maxConstraints?.count ?? 0,
releaseTemplates, releaseTemplates,
releasePlans, releasePlans,
edgeInstances, edgeInstanceUsage,
}; };
} }
@ -481,7 +481,7 @@ export class InstanceStatsService {
hostedBy, hostedBy,
releaseTemplates, releaseTemplates,
releasePlans, releasePlans,
edgeInstances, edgeInstanceUsage,
] = await Promise.all([ ] = await Promise.all([
this.getToggleCount(), this.getToggleCount(),
this.getRegisteredUsers(), this.getRegisteredUsers(),
@ -545,8 +545,7 @@ export class InstanceStatsService {
hostedBy, hostedBy,
releaseTemplates, releaseTemplates,
releasePlans, releasePlans,
edgeInstancesLastMonth: edgeInstances.lastMonth, edgeInstanceUsage,
edgeInstancesMonthBeforeLast: edgeInstances.monthBeforeLast,
}; };
return featureInfo; return featureInfo;
} }

View File

@ -284,25 +284,18 @@ export const instanceAdminStatsSchema = {
example: 1, example: 1,
description: 'The number of release plans in this instance', description: 'The number of release plans in this instance',
}, },
edgeInstances: { edgeInstanceUsage: {
type: 'object', type: 'object',
description: description:
'The rounded up average number of edge instances in the last month and month before last', 'The average number of edge instances, per month, in the last 12 months, rounded to 3 decimal places',
properties: { additionalProperties: {
lastMonth: { type: 'number',
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, minimum: 0,
}, },
example: {
'2025-09': 2.25,
'2025-08': 1.75,
'2024-10': 0.45,
}, },
}, },
sum: { sum: {

View File

@ -133,9 +133,10 @@ class InstanceAdminController extends Controller {
maxConstraintValues: 123, maxConstraintValues: 123,
releaseTemplates: 3, releaseTemplates: 3,
releasePlans: 5, releasePlans: 5,
edgeInstances: { edgeInstanceUsage: {
lastMonth: 10, '2022-06': 2.345,
monthBeforeLast: 15, '2022-07': 2.567,
'2022-08': 2.789,
}, },
}; };
} }

View File

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

View File

@ -4,6 +4,7 @@ import type { IUnleashConfig } from '../types/option.js';
import version from '../util/version.js'; import version from '../util/version.js';
import type { Logger } from '../logger.js'; import type { Logger } from '../logger.js';
import type { ISettingStore } from '../types/stores/settings-store.js'; import type { ISettingStore } from '../types/stores/settings-store.js';
import type { EdgeInstanceUsage } from '../features/instance-stats/getEdgeInstances.js';
export interface IVersionInfo { export interface IVersionInfo {
oss: string; oss: string;
@ -54,8 +55,7 @@ export interface IFeatureUsageInfo {
hostedBy: string; hostedBy: string;
releaseTemplates: number; releaseTemplates: number;
releasePlans: number; releasePlans: number;
edgeInstancesLastMonth?: number; edgeInstanceUsage?: EdgeInstanceUsage;
edgeInstancesMonthBeforeLast?: number;
} }
export default class VersionService { export default class VersionService {