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 './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';

View File

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

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

View File

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

View File

@ -75,7 +75,7 @@ export interface InstanceStats {
maxConstraintValues: number;
releaseTemplates?: number;
releasePlans?: number;
edgeInstances?: Awaited<ReturnType<GetEdgeInstances>>;
edgeInstanceUsage?: Awaited<ReturnType<GetEdgeInstances>>;
}
export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & {
@ -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;
}

View File

@ -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,
'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: {

View File

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

View File

@ -45,8 +45,7 @@ const fakeTelemetryData = {
hostedBy: 'self-hosted',
releaseTemplates: 2,
releasePlans: 4,
edgeInstancesLastMonth: 0,
edgeInstancesMonthBeforeLast: 0,
edgeInstanceUsage: {},
};
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 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 {