mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-09 01:17:06 +02:00
feat: introduce new term licensed users (#8737)
Introducing new term Licensed users. Added query to read it from database and extensive tests to cover the logic.
This commit is contained in:
parent
bc7511abd4
commit
940182aaf0
@ -44,6 +44,10 @@ import { FeatureStrategiesReadModel } from '../feature-toggle/feature-strategies
|
|||||||
import { FakeFeatureStrategiesReadModel } from '../feature-toggle/fake-feature-strategies-read-model';
|
import { FakeFeatureStrategiesReadModel } from '../feature-toggle/fake-feature-strategies-read-model';
|
||||||
import { TrafficDataUsageStore } from '../traffic-data-usage/traffic-data-usage-store';
|
import { TrafficDataUsageStore } from '../traffic-data-usage/traffic-data-usage-store';
|
||||||
import { FakeTrafficDataUsageStore } from '../traffic-data-usage/fake-traffic-data-usage-store';
|
import { FakeTrafficDataUsageStore } from '../traffic-data-usage/fake-traffic-data-usage-store';
|
||||||
|
import {
|
||||||
|
createFakeGetLicensedUsers,
|
||||||
|
createGetLicensedUsers,
|
||||||
|
} from './getLicensedUsers';
|
||||||
|
|
||||||
export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => {
|
export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => {
|
||||||
const { eventBus, getLogger, flagResolver } = config;
|
const { eventBus, getLogger, flagResolver } = config;
|
||||||
@ -128,6 +132,7 @@ export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => {
|
|||||||
};
|
};
|
||||||
const getActiveUsers = createGetActiveUsers(db);
|
const getActiveUsers = createGetActiveUsers(db);
|
||||||
const getProductionChanges = createGetProductionChanges(db);
|
const getProductionChanges = createGetProductionChanges(db);
|
||||||
|
const getLicencedUsers = createGetLicensedUsers(db);
|
||||||
const versionService = new VersionService(
|
const versionService = new VersionService(
|
||||||
versionServiceStores,
|
versionServiceStores,
|
||||||
config,
|
config,
|
||||||
@ -141,6 +146,7 @@ export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => {
|
|||||||
versionService,
|
versionService,
|
||||||
getActiveUsers,
|
getActiveUsers,
|
||||||
getProductionChanges,
|
getProductionChanges,
|
||||||
|
getLicencedUsers,
|
||||||
);
|
);
|
||||||
|
|
||||||
return instanceStatsService;
|
return instanceStatsService;
|
||||||
@ -189,6 +195,7 @@ export const createFakeInstanceStatsService = (config: IUnleashConfig) => {
|
|||||||
featureStrategiesStore,
|
featureStrategiesStore,
|
||||||
};
|
};
|
||||||
const getActiveUsers = createFakeGetActiveUsers();
|
const getActiveUsers = createFakeGetActiveUsers();
|
||||||
|
const getLicensedUsers = createFakeGetLicensedUsers();
|
||||||
const getProductionChanges = createFakeGetProductionChanges();
|
const getProductionChanges = createFakeGetProductionChanges();
|
||||||
const versionService = new VersionService(
|
const versionService = new VersionService(
|
||||||
versionServiceStores,
|
versionServiceStores,
|
||||||
@ -203,6 +210,7 @@ export const createFakeInstanceStatsService = (config: IUnleashConfig) => {
|
|||||||
versionService,
|
versionService,
|
||||||
getActiveUsers,
|
getActiveUsers,
|
||||||
getProductionChanges,
|
getProductionChanges,
|
||||||
|
getLicensedUsers,
|
||||||
);
|
);
|
||||||
|
|
||||||
return instanceStatsService;
|
return instanceStatsService;
|
||||||
|
63
src/lib/features/instance-stats/getLicensedUsers.e2e.test.ts
Normal file
63
src/lib/features/instance-stats/getLicensedUsers.e2e.test.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
createGetLicensedUsers,
|
||||||
|
type GetLicensedUsers,
|
||||||
|
} from './getLicensedUsers';
|
||||||
|
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
|
||||||
|
import getLogger from '../../../test/fixtures/no-logger';
|
||||||
|
|
||||||
|
let db: ITestDb;
|
||||||
|
let getLicensedUsers: GetLicensedUsers;
|
||||||
|
|
||||||
|
const mockUser = (deletedDaysAgo: number | null, uniqueId: number) => {
|
||||||
|
const deletedAt =
|
||||||
|
deletedDaysAgo !== null
|
||||||
|
? new Date(Date.now() - deletedDaysAgo * 24 * 60 * 60 * 1000)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
email: `${uniqueId}.user@example.com`,
|
||||||
|
email_hash: `${uniqueId}.user@example.com`,
|
||||||
|
deleted_at: deletedAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = await dbInit('licensed_users_serial', getLogger);
|
||||||
|
getLicensedUsers = createGetLicensedUsers(db.rawDatabase);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.rawDatabase('users').delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 0 users when no users are present', async () => {
|
||||||
|
await expect(getLicensedUsers()).resolves.toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 1 active user with no deletion date', async () => {
|
||||||
|
await db.rawDatabase('users').insert(mockUser(null, 1));
|
||||||
|
await expect(getLicensedUsers()).resolves.toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should count user as active if deleted within 30 days', async () => {
|
||||||
|
await db.rawDatabase('users').insert(mockUser(29, 2));
|
||||||
|
await expect(getLicensedUsers()).resolves.toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not count user as active if deleted more than 30 days ago', async () => {
|
||||||
|
await db.rawDatabase('users').insert(mockUser(31, 3));
|
||||||
|
await expect(getLicensedUsers()).resolves.toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct count for multiple users with mixed deletion statuses', async () => {
|
||||||
|
const users = [
|
||||||
|
...Array.from({ length: 10 }, (_, userId) => mockUser(null, userId)), // 10 active users
|
||||||
|
...Array.from({ length: 5 }, (_, userId) => mockUser(29, userId + 10)), // 5 users deleted within 30 days
|
||||||
|
...Array.from({ length: 3 }, (_, userId) => mockUser(31, userId + 15)), // 3 users deleted more than 30 days ago
|
||||||
|
];
|
||||||
|
await db.rawDatabase('users').insert(users);
|
||||||
|
await expect(getLicensedUsers()).resolves.toEqual(15);
|
||||||
|
});
|
28
src/lib/features/instance-stats/getLicensedUsers.ts
Normal file
28
src/lib/features/instance-stats/getLicensedUsers.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import type { Db } from '../../server-impl';
|
||||||
|
|
||||||
|
export type GetLicensedUsers = () => Promise<number>;
|
||||||
|
|
||||||
|
export const createGetLicensedUsers =
|
||||||
|
(db: Db): GetLicensedUsers =>
|
||||||
|
async () => {
|
||||||
|
const result = await db('users')
|
||||||
|
.countDistinct('email_hash as activeCount')
|
||||||
|
.whereNotNull('email_hash')
|
||||||
|
.andWhere(function () {
|
||||||
|
this.whereNull('deleted_at').orWhere(
|
||||||
|
'deleted_at',
|
||||||
|
'>=',
|
||||||
|
db.raw("NOW() - INTERVAL '30 days'"),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return Number(result?.activeCount ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFakeGetLicensedUsers =
|
||||||
|
(
|
||||||
|
licencedUsers: Awaited<ReturnType<GetLicensedUsers>> = 0,
|
||||||
|
): GetLicensedUsers =>
|
||||||
|
() =>
|
||||||
|
Promise.resolve(licencedUsers);
|
@ -7,6 +7,7 @@ import { createFakeGetProductionChanges } from './getProductionChanges';
|
|||||||
import { registerPrometheusMetrics } from '../../metrics';
|
import { registerPrometheusMetrics } from '../../metrics';
|
||||||
import { register } from 'prom-client';
|
import { register } from 'prom-client';
|
||||||
import type { IClientInstanceStore } from '../../types';
|
import type { IClientInstanceStore } from '../../types';
|
||||||
|
import { createFakeGetLicensedUsers } from './getLicensedUsers';
|
||||||
let instanceStatsService: InstanceStatsService;
|
let instanceStatsService: InstanceStatsService;
|
||||||
let versionService: VersionService;
|
let versionService: VersionService;
|
||||||
let clientInstanceStore: IClientInstanceStore;
|
let clientInstanceStore: IClientInstanceStore;
|
||||||
@ -31,6 +32,7 @@ beforeEach(() => {
|
|||||||
versionService,
|
versionService,
|
||||||
createFakeGetActiveUsers(),
|
createFakeGetActiveUsers(),
|
||||||
createFakeGetProductionChanges(),
|
createFakeGetProductionChanges(),
|
||||||
|
createFakeGetLicensedUsers(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { collectAggDbMetrics } = registerPrometheusMetrics(
|
const { collectAggDbMetrics } = registerPrometheusMetrics(
|
||||||
|
@ -31,6 +31,7 @@ import type { GetActiveUsers } from './getActiveUsers';
|
|||||||
import type { ProjectModeCount } from '../project/project-store';
|
import type { ProjectModeCount } from '../project/project-store';
|
||||||
import type { GetProductionChanges } from './getProductionChanges';
|
import type { GetProductionChanges } from './getProductionChanges';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import type { GetLicensedUsers } from './getLicensedUsers';
|
||||||
|
|
||||||
export type TimeRange = 'allTime' | '30d' | '7d';
|
export type TimeRange = 'allTime' | '30d' | '7d';
|
||||||
|
|
||||||
@ -59,6 +60,7 @@ export interface InstanceStats {
|
|||||||
OIDCenabled: boolean;
|
OIDCenabled: boolean;
|
||||||
clientApps: { range: TimeRange; count: number }[];
|
clientApps: { range: TimeRange; count: number }[];
|
||||||
activeUsers: Awaited<ReturnType<GetActiveUsers>>;
|
activeUsers: Awaited<ReturnType<GetActiveUsers>>;
|
||||||
|
licensedUsers: Awaited<ReturnType<GetLicensedUsers>>;
|
||||||
productionChanges: Awaited<ReturnType<GetProductionChanges>>;
|
productionChanges: Awaited<ReturnType<GetProductionChanges>>;
|
||||||
previousDayMetricsBucketsCount: {
|
previousDayMetricsBucketsCount: {
|
||||||
enabledCount: number;
|
enabledCount: number;
|
||||||
@ -113,6 +115,8 @@ export class InstanceStatsService {
|
|||||||
|
|
||||||
getActiveUsers: GetActiveUsers;
|
getActiveUsers: GetActiveUsers;
|
||||||
|
|
||||||
|
getLicencedUsers: GetLicensedUsers;
|
||||||
|
|
||||||
getProductionChanges: GetProductionChanges;
|
getProductionChanges: GetProductionChanges;
|
||||||
|
|
||||||
private featureStrategiesReadModel: IFeatureStrategiesReadModel;
|
private featureStrategiesReadModel: IFeatureStrategiesReadModel;
|
||||||
@ -163,6 +167,7 @@ export class InstanceStatsService {
|
|||||||
versionService: VersionService,
|
versionService: VersionService,
|
||||||
getActiveUsers: GetActiveUsers,
|
getActiveUsers: GetActiveUsers,
|
||||||
getProductionChanges: GetProductionChanges,
|
getProductionChanges: GetProductionChanges,
|
||||||
|
getLicencedUsers: GetLicensedUsers,
|
||||||
) {
|
) {
|
||||||
this.strategyStore = strategyStore;
|
this.strategyStore = strategyStore;
|
||||||
this.userStore = userStore;
|
this.userStore = userStore;
|
||||||
@ -179,6 +184,7 @@ export class InstanceStatsService {
|
|||||||
this.clientInstanceStore = clientInstanceStore;
|
this.clientInstanceStore = clientInstanceStore;
|
||||||
this.logger = getLogger('services/stats-service.js');
|
this.logger = getLogger('services/stats-service.js');
|
||||||
this.getActiveUsers = getActiveUsers;
|
this.getActiveUsers = getActiveUsers;
|
||||||
|
this.getLicencedUsers = getLicencedUsers;
|
||||||
this.getProductionChanges = getProductionChanges;
|
this.getProductionChanges = getProductionChanges;
|
||||||
this.apiTokenStore = apiTokenStore;
|
this.apiTokenStore = apiTokenStore;
|
||||||
this.clientMetricsStore = clientMetricsStoreV2;
|
this.clientMetricsStore = clientMetricsStoreV2;
|
||||||
@ -247,6 +253,7 @@ export class InstanceStatsService {
|
|||||||
serviceAccounts,
|
serviceAccounts,
|
||||||
apiTokens,
|
apiTokens,
|
||||||
activeUsers,
|
activeUsers,
|
||||||
|
licensedUsers,
|
||||||
projects,
|
projects,
|
||||||
contextFields,
|
contextFields,
|
||||||
groups,
|
groups,
|
||||||
@ -275,6 +282,7 @@ export class InstanceStatsService {
|
|||||||
this.countServiceAccounts(),
|
this.countServiceAccounts(),
|
||||||
this.countApiTokensByType(),
|
this.countApiTokensByType(),
|
||||||
this.getActiveUsers(),
|
this.getActiveUsers(),
|
||||||
|
this.getLicencedUsers(),
|
||||||
this.getProjectModeCount(),
|
this.getProjectModeCount(),
|
||||||
this.contextFieldCount(),
|
this.contextFieldCount(),
|
||||||
this.groupCount(),
|
this.groupCount(),
|
||||||
@ -311,6 +319,7 @@ export class InstanceStatsService {
|
|||||||
serviceAccounts,
|
serviceAccounts,
|
||||||
apiTokens,
|
apiTokens,
|
||||||
activeUsers,
|
activeUsers,
|
||||||
|
licensedUsers,
|
||||||
featureToggles,
|
featureToggles,
|
||||||
archivedFeatureToggles,
|
archivedFeatureToggles,
|
||||||
projects,
|
projects,
|
||||||
|
@ -38,6 +38,7 @@ import getLogger from '../test/fixtures/no-logger';
|
|||||||
import dbInit, { type ITestDb } from '../test/e2e/helpers/database-init';
|
import dbInit, { type ITestDb } from '../test/e2e/helpers/database-init';
|
||||||
import { FeatureLifecycleStore } from './features/feature-lifecycle/feature-lifecycle-store';
|
import { FeatureLifecycleStore } from './features/feature-lifecycle/feature-lifecycle-store';
|
||||||
import { FeatureLifecycleReadModel } from './features/feature-lifecycle/feature-lifecycle-read-model';
|
import { FeatureLifecycleReadModel } from './features/feature-lifecycle/feature-lifecycle-read-model';
|
||||||
|
import { createFakeGetLicensedUsers } from './features/instance-stats/getLicensedUsers';
|
||||||
|
|
||||||
const monitor = createMetricsMonitor();
|
const monitor = createMetricsMonitor();
|
||||||
const eventBus = new EventEmitter();
|
const eventBus = new EventEmitter();
|
||||||
@ -84,6 +85,7 @@ beforeAll(async () => {
|
|||||||
versionService,
|
versionService,
|
||||||
createFakeGetActiveUsers(),
|
createFakeGetActiveUsers(),
|
||||||
createFakeGetProductionChanges(),
|
createFakeGetProductionChanges(),
|
||||||
|
createFakeGetLicensedUsers(),
|
||||||
);
|
);
|
||||||
|
|
||||||
schedulerService = new SchedulerService(
|
schedulerService = new SchedulerService(
|
||||||
|
@ -95,6 +95,13 @@ export const instanceAdminStatsSchema = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
licensedUsers: {
|
||||||
|
type: 'integer',
|
||||||
|
description:
|
||||||
|
'The number of users who had access to Unleash within the last 30 days, including those who may have been deleted during this period.',
|
||||||
|
example: 10,
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
productionChanges: {
|
productionChanges: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description:
|
description:
|
||||||
|
@ -107,6 +107,7 @@ class InstanceAdminController extends Controller {
|
|||||||
sum: 'some-sha256-hash',
|
sum: 'some-sha256-hash',
|
||||||
timestamp: new Date(2023, 6, 12, 10, 0, 0, 0),
|
timestamp: new Date(2023, 6, 12, 10, 0, 0, 0),
|
||||||
users: 10,
|
users: 10,
|
||||||
|
licensedUsers: 12,
|
||||||
serviceAccounts: 2,
|
serviceAccounts: 2,
|
||||||
apiTokens: new Map([]),
|
apiTokens: new Map([]),
|
||||||
versionEnterprise: '5.1.7',
|
versionEnterprise: '5.1.7',
|
||||||
|
Loading…
Reference in New Issue
Block a user