1
0
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:
Jaanus Sellin 2024-11-13 14:32:58 +02:00 committed by GitHub
parent bc7511abd4
commit 940182aaf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 120 additions and 0 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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