mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-31 01:16:01 +02:00
feat: Add active users statistics to metrics (#4674)
## About the changes - `getActiveUsers` is using multiple stores, so it is refactored into read-model - Refactored Instance stats service into `features` to co-locate related code Closes https://linear.app/unleash/issue/UNL-230/active-users-prometheus ### Important files `src/lib/features/instance-stats/getActiveUsers.ts` ## Discussion points `getActiveUsers` is coded less _class-based_ then previous similar read-models. In one file instead of 3 (read-model interface, fake read model, sql read model). I find types and functions way more readable, but I'm ready to refactor it to interfaces and classes if consistency is more important.
This commit is contained in:
parent
4484615321
commit
2c826bdbba
@ -201,31 +201,6 @@ class UserStore implements IUserStore {
|
||||
.then((res) => Number(res[0].count));
|
||||
}
|
||||
|
||||
async getActiveUsersCount(): Promise<{
|
||||
last7: number;
|
||||
last30: number;
|
||||
last90: number;
|
||||
}> {
|
||||
const result = await this.db.raw(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM ${TABLE} WHERE seen_at > NOW() - INTERVAL '1 week') AS last_week,
|
||||
(SELECT COUNT(*) FROM ${TABLE} WHERE seen_at > NOW() - INTERVAL '1 month') AS last_month,
|
||||
(SELECT COUNT(*) FROM ${TABLE} WHERE seen_at > NOW() - INTERVAL '3 months') AS last_quarter`,
|
||||
);
|
||||
|
||||
const {
|
||||
last_week: last7,
|
||||
last_month: last30,
|
||||
last_quarter: last90,
|
||||
} = result.rows[0];
|
||||
|
||||
return {
|
||||
last7,
|
||||
last30,
|
||||
last90,
|
||||
};
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
|
||||
async exists(id: number): Promise<boolean> {
|
||||
|
154
src/lib/features/instance-stats/getActiveUsers.e2e.test.ts
Normal file
154
src/lib/features/instance-stats/getActiveUsers.e2e.test.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { createGetActiveUsers, type GetActiveUsers } from './getActiveUsers';
|
||||
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
|
||||
import getLogger from '../../../test/fixtures/no-logger';
|
||||
|
||||
let db: ITestDb;
|
||||
let getActiveUsers: GetActiveUsers;
|
||||
|
||||
const mockUserDaysAgo = (days: number) => {
|
||||
const result = new Date();
|
||||
result.setDate(result.getDate() - days);
|
||||
return {
|
||||
email: `${days}.user@example.com`,
|
||||
seen_at: result,
|
||||
};
|
||||
};
|
||||
|
||||
const mockTokenDaysAgo = (userId: number, days: number) => {
|
||||
const result = new Date();
|
||||
result.setDate(result.getDate() - days);
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
seen_at: result,
|
||||
secret: 'secret',
|
||||
expires_at: new Date('2031-12-31'),
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('active_users_serial', getLogger);
|
||||
getActiveUsers = createGetActiveUsers(db.rawDatabase);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.rawDatabase('users').delete();
|
||||
await db.rawDatabase('personal_access_tokens').delete();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('should return 0 users', async () => {
|
||||
expect(getActiveUsers()).resolves.toEqual({
|
||||
last7: 0,
|
||||
last30: 0,
|
||||
last60: 0,
|
||||
last90: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 1 user', async () => {
|
||||
await db.rawDatabase('users').insert(mockUserDaysAgo(1));
|
||||
|
||||
expect(getActiveUsers()).resolves.toEqual({
|
||||
last7: 1,
|
||||
last30: 1,
|
||||
last60: 1,
|
||||
last90: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle intervals of activity', async () => {
|
||||
await db
|
||||
.rawDatabase('users')
|
||||
.insert([
|
||||
mockUserDaysAgo(5),
|
||||
mockUserDaysAgo(10),
|
||||
mockUserDaysAgo(20),
|
||||
mockUserDaysAgo(40),
|
||||
mockUserDaysAgo(70),
|
||||
mockUserDaysAgo(100),
|
||||
]);
|
||||
|
||||
expect(getActiveUsers()).resolves.toEqual({
|
||||
last7: 1,
|
||||
last30: 3,
|
||||
last60: 4,
|
||||
last90: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('should count user as active if they have an active token', async () => {
|
||||
const users = await db
|
||||
.rawDatabase('users')
|
||||
.insert(mockUserDaysAgo(100))
|
||||
.returning('id');
|
||||
const userId = users[0].id;
|
||||
await db
|
||||
.rawDatabase('personal_access_tokens')
|
||||
.insert(mockTokenDaysAgo(userId, 31));
|
||||
|
||||
expect(getActiveUsers()).resolves.toEqual({
|
||||
last7: 0,
|
||||
last30: 0,
|
||||
last60: 1,
|
||||
last90: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should prioritize user seen_at if newer then token seen_at', async () => {
|
||||
const users = await db
|
||||
.rawDatabase('users')
|
||||
.insert(mockUserDaysAgo(14))
|
||||
.returning('id');
|
||||
const userId = users[0].id;
|
||||
await db
|
||||
.rawDatabase('personal_access_tokens')
|
||||
.insert([
|
||||
mockTokenDaysAgo(userId, 31),
|
||||
mockTokenDaysAgo(userId, 61),
|
||||
mockTokenDaysAgo(userId, 91),
|
||||
]);
|
||||
|
||||
expect(getActiveUsers()).resolves.toEqual({
|
||||
last7: 0,
|
||||
last30: 1,
|
||||
last60: 1,
|
||||
last90: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle multiple users and with multiple tokens', async () => {
|
||||
const users = await db
|
||||
.rawDatabase('users')
|
||||
.insert([
|
||||
mockUserDaysAgo(5),
|
||||
mockUserDaysAgo(10),
|
||||
mockUserDaysAgo(20),
|
||||
mockUserDaysAgo(40),
|
||||
mockUserDaysAgo(70),
|
||||
mockUserDaysAgo(100),
|
||||
])
|
||||
.returning('id');
|
||||
|
||||
await db
|
||||
.rawDatabase('personal_access_tokens')
|
||||
.insert([
|
||||
mockTokenDaysAgo(users[0].id, 31),
|
||||
mockTokenDaysAgo(users[1].id, 61),
|
||||
mockTokenDaysAgo(users[1].id, 15),
|
||||
mockTokenDaysAgo(users[1].id, 55),
|
||||
mockTokenDaysAgo(users[2].id, 4),
|
||||
mockTokenDaysAgo(users[3].id, 91),
|
||||
mockTokenDaysAgo(users[4].id, 91),
|
||||
]);
|
||||
|
||||
expect(getActiveUsers()).resolves.toEqual({
|
||||
last7: 2,
|
||||
last30: 3,
|
||||
last60: 4,
|
||||
last90: 5,
|
||||
});
|
||||
});
|
56
src/lib/features/instance-stats/getActiveUsers.ts
Normal file
56
src/lib/features/instance-stats/getActiveUsers.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { type Db } from 'lib/server-impl';
|
||||
|
||||
export type GetActiveUsers = () => Promise<{
|
||||
last7: number;
|
||||
last30: number;
|
||||
last60: number;
|
||||
last90: number;
|
||||
}>;
|
||||
|
||||
export const createGetActiveUsers =
|
||||
(db: Db): GetActiveUsers =>
|
||||
async () => {
|
||||
const combinedQuery = db
|
||||
.select('id as user_id', 'seen_at')
|
||||
.from('users')
|
||||
.unionAll(
|
||||
db.select('user_id', 'seen_at').from('personal_access_tokens'),
|
||||
);
|
||||
|
||||
const result = await db
|
||||
.with('Combined', combinedQuery)
|
||||
.select({
|
||||
last_week: db.raw(
|
||||
"COUNT(DISTINCT CASE WHEN seen_at > NOW() - INTERVAL '1 week' THEN user_id END)",
|
||||
),
|
||||
last_month: db.raw(
|
||||
"COUNT(DISTINCT CASE WHEN seen_at > NOW() - INTERVAL '1 month' THEN user_id END)",
|
||||
),
|
||||
last_two_months: db.raw(
|
||||
"COUNT(DISTINCT CASE WHEN seen_at > NOW() - INTERVAL '2 months' THEN user_id END)",
|
||||
),
|
||||
last_quarter: db.raw(
|
||||
"COUNT(DISTINCT CASE WHEN seen_at > NOW() - INTERVAL '3 months' THEN user_id END)",
|
||||
),
|
||||
})
|
||||
.from('Combined');
|
||||
|
||||
return {
|
||||
last7: parseInt(result?.[0]?.last_week || '0', 10),
|
||||
last30: parseInt(result?.[0]?.last_month || '0', 10),
|
||||
last60: parseInt(result?.[0]?.last_two_months || '0', 10),
|
||||
last90: parseInt(result?.[0]?.last_quarter || '0', 10),
|
||||
};
|
||||
};
|
||||
|
||||
export const createFakeGetActiveUsers =
|
||||
(
|
||||
activeUsers: Awaited<ReturnType<GetActiveUsers>> = {
|
||||
last7: 0,
|
||||
last30: 0,
|
||||
last60: 0,
|
||||
last90: 0,
|
||||
},
|
||||
): GetActiveUsers =>
|
||||
() =>
|
||||
Promise.resolve(activeUsers);
|
@ -1,7 +1,8 @@
|
||||
import { createTestConfig } from '../../test/config/test-config';
|
||||
import { createTestConfig } from '../../../test/config/test-config';
|
||||
import { InstanceStatsService } from './instance-stats-service';
|
||||
import createStores from '../../test/fixtures/store';
|
||||
import VersionService from './version-service';
|
||||
import createStores from '../../../test/fixtures/store';
|
||||
import VersionService from '../../services/version-service';
|
||||
import { createFakeGetActiveUsers } from './getActiveUsers';
|
||||
|
||||
let instanceStatsService: InstanceStatsService;
|
||||
let versionService: VersionService;
|
||||
@ -14,6 +15,7 @@ beforeEach(() => {
|
||||
stores,
|
||||
config,
|
||||
versionService,
|
||||
createFakeGetActiveUsers(),
|
||||
);
|
||||
|
||||
jest.spyOn(instanceStatsService, 'refreshStatsSnapshot');
|
@ -1,24 +1,25 @@
|
||||
import { sha256 } from 'js-sha256';
|
||||
import { Logger } from '../logger';
|
||||
import { IUnleashConfig } from '../types/option';
|
||||
import { Logger } from '../../logger';
|
||||
import { IUnleashConfig } from '../../types/option';
|
||||
import {
|
||||
IClientInstanceStore,
|
||||
IEventStore,
|
||||
IUnleashStores,
|
||||
} from '../types/stores';
|
||||
import { IContextFieldStore } from '../types/stores/context-field-store';
|
||||
import { IEnvironmentStore } from '../types/stores/environment-store';
|
||||
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
|
||||
import { IGroupStore } from '../types/stores/group-store';
|
||||
import { IProjectStore } from '../types/stores/project-store';
|
||||
import { IStrategyStore } from '../types/stores/strategy-store';
|
||||
import { IActiveUsers, IUserStore } from '../types/stores/user-store';
|
||||
import { ISegmentStore } from '../types/stores/segment-store';
|
||||
import { IRoleStore } from '../types/stores/role-store';
|
||||
import VersionService from './version-service';
|
||||
import { ISettingStore } from '../types/stores/settings-store';
|
||||
import { FEATURES_EXPORTED, FEATURES_IMPORTED } from '../types';
|
||||
import { CUSTOM_ROOT_ROLE_TYPE } from '../util';
|
||||
} from '../../types/stores';
|
||||
import { IContextFieldStore } from '../../types/stores/context-field-store';
|
||||
import { IEnvironmentStore } from '../../types/stores/environment-store';
|
||||
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
|
||||
import { IGroupStore } from '../../types/stores/group-store';
|
||||
import { IProjectStore } from '../../types/stores/project-store';
|
||||
import { IStrategyStore } from '../../types/stores/strategy-store';
|
||||
import { IUserStore } from '../../types/stores/user-store';
|
||||
import { ISegmentStore } from '../../types/stores/segment-store';
|
||||
import { IRoleStore } from '../../types/stores/role-store';
|
||||
import VersionService from '../../services/version-service';
|
||||
import { ISettingStore } from '../../types/stores/settings-store';
|
||||
import { FEATURES_EXPORTED, FEATURES_IMPORTED } from '../../types';
|
||||
import { CUSTOM_ROOT_ROLE_TYPE } from '../../util';
|
||||
import { type GetActiveUsers } from './getActiveUsers';
|
||||
|
||||
export type TimeRange = 'allTime' | '30d' | '7d';
|
||||
|
||||
@ -43,7 +44,7 @@ export interface InstanceStats {
|
||||
SAMLenabled: boolean;
|
||||
OIDCenabled: boolean;
|
||||
clientApps: { range: TimeRange; count: number }[];
|
||||
activeUsers: IActiveUsers;
|
||||
activeUsers: Awaited<ReturnType<GetActiveUsers>>;
|
||||
}
|
||||
|
||||
export interface InstanceStatsSigned extends InstanceStats {
|
||||
@ -83,6 +84,8 @@ export class InstanceStatsService {
|
||||
|
||||
private appCount?: Partial<{ [key in TimeRange]: number }>;
|
||||
|
||||
private getActiveUsers: GetActiveUsers;
|
||||
|
||||
constructor(
|
||||
{
|
||||
featureToggleStore,
|
||||
@ -114,6 +117,7 @@ export class InstanceStatsService {
|
||||
>,
|
||||
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
|
||||
versionService: VersionService,
|
||||
getActiveUsers: GetActiveUsers,
|
||||
) {
|
||||
this.strategyStore = strategyStore;
|
||||
this.userStore = userStore;
|
||||
@ -129,6 +133,7 @@ export class InstanceStatsService {
|
||||
this.eventStore = eventStore;
|
||||
this.clientInstanceStore = clientInstanceStore;
|
||||
this.logger = getLogger('services/stats-service.js');
|
||||
this.getActiveUsers = getActiveUsers;
|
||||
}
|
||||
|
||||
async refreshStatsSnapshot(): Promise<void> {
|
||||
@ -195,7 +200,7 @@ export class InstanceStatsService {
|
||||
] = await Promise.all([
|
||||
this.getToggleCount(),
|
||||
this.userStore.count(),
|
||||
this.userStore.getActiveUsersCount(),
|
||||
this.getActiveUsers(),
|
||||
this.projectStore.count(),
|
||||
this.contextFieldStore.count(),
|
||||
this.groupStore.count(),
|
@ -10,8 +10,9 @@ import {
|
||||
} from './types/events';
|
||||
import { createMetricsMonitor } from './metrics';
|
||||
import createStores from '../test/fixtures/store';
|
||||
import { InstanceStatsService } from './services/instance-stats-service';
|
||||
import { InstanceStatsService } from './features/instance-stats/instance-stats-service';
|
||||
import VersionService from './services/version-service';
|
||||
import { createFakeGetActiveUsers } from './features/instance-stats/getActiveUsers';
|
||||
|
||||
const monitor = createMetricsMonitor();
|
||||
const eventBus = new EventEmitter();
|
||||
@ -28,7 +29,13 @@ beforeAll(() => {
|
||||
stores = createStores();
|
||||
eventStore = stores.eventStore;
|
||||
const versionService = new VersionService(stores, config);
|
||||
statsService = new InstanceStatsService(stores, config, versionService);
|
||||
statsService = new InstanceStatsService(
|
||||
stores,
|
||||
config,
|
||||
versionService,
|
||||
createFakeGetActiveUsers(),
|
||||
);
|
||||
|
||||
const db = {
|
||||
client: {
|
||||
pool: {
|
||||
|
@ -22,7 +22,7 @@ import { IUnleashConfig } from './types/option';
|
||||
import { IUnleashStores } from './types/stores';
|
||||
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
|
||||
import Timer = NodeJS.Timer;
|
||||
import { InstanceStatsService } from './services/instance-stats-service';
|
||||
import { InstanceStatsService } from './features/instance-stats/instance-stats-service';
|
||||
import { ValidatedClientMetrics } from './services/client-metrics/schema';
|
||||
|
||||
export default class MetricsMonitor {
|
||||
@ -86,6 +86,22 @@ export default class MetricsMonitor {
|
||||
name: 'users_total',
|
||||
help: 'Number of users',
|
||||
});
|
||||
const usersActive7days = new client.Gauge({
|
||||
name: 'users_active_7',
|
||||
help: 'Number of users active in the last 7 days',
|
||||
});
|
||||
const usersActive30days = new client.Gauge({
|
||||
name: 'users_active_30',
|
||||
help: 'Number of users active in the last 30 days',
|
||||
});
|
||||
const usersActive60days = new client.Gauge({
|
||||
name: 'users_active_60',
|
||||
help: 'Number of users active in the last 60 days',
|
||||
});
|
||||
const usersActive90days = new client.Gauge({
|
||||
name: 'users_active_90',
|
||||
help: 'Number of users active in the last 90 days',
|
||||
});
|
||||
const projectsTotal = new client.Gauge({
|
||||
name: 'projects_total',
|
||||
help: 'Number of projects',
|
||||
@ -167,6 +183,15 @@ export default class MetricsMonitor {
|
||||
usersTotal.reset();
|
||||
usersTotal.set(stats.users);
|
||||
|
||||
usersActive7days.reset();
|
||||
usersActive7days.set(stats.activeUsers.last7);
|
||||
usersActive30days.reset();
|
||||
usersActive30days.set(stats.activeUsers.last30);
|
||||
usersActive60days.reset();
|
||||
usersActive60days.set(stats.activeUsers.last60);
|
||||
usersActive90days.reset();
|
||||
usersActive90days.set(stats.activeUsers.last90);
|
||||
|
||||
projectsTotal.reset();
|
||||
projectsTotal.set(stats.projects);
|
||||
|
||||
|
@ -58,6 +58,13 @@ export const instanceAdminStatsSchema = {
|
||||
example: 10,
|
||||
minimum: 0,
|
||||
},
|
||||
last60: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The number of active users in the last 60 days',
|
||||
example: 12,
|
||||
minimum: 0,
|
||||
},
|
||||
last90: {
|
||||
type: 'number',
|
||||
description:
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
InstanceStats,
|
||||
InstanceStatsService,
|
||||
InstanceStatsSigned,
|
||||
} from '../../services/instance-stats-service';
|
||||
} from '../../features/instance-stats/instance-stats-service';
|
||||
import { OpenApiService } from '../../services/openapi-service';
|
||||
import {
|
||||
createCsvResponseSchema,
|
||||
@ -110,6 +110,7 @@ class InstanceAdminController extends Controller {
|
||||
versionOSS: '5.1.7',
|
||||
activeUsers: {
|
||||
last90: 15,
|
||||
last60: 12,
|
||||
last30: 10,
|
||||
last7: 5,
|
||||
},
|
||||
|
@ -36,7 +36,7 @@ import EdgeService from './edge-service';
|
||||
import PatService from './pat-service';
|
||||
import { PublicSignupTokenService } from './public-signup-token-service';
|
||||
import { LastSeenService } from './client-metrics/last-seen-service';
|
||||
import { InstanceStatsService } from './instance-stats-service';
|
||||
import { InstanceStatsService } from '../features/instance-stats/instance-stats-service';
|
||||
import { FavoritesService } from './favorites-service';
|
||||
import MaintenanceService from './maintenance-service';
|
||||
import {
|
||||
@ -64,6 +64,10 @@ import {
|
||||
createFakePrivateProjectChecker,
|
||||
createPrivateProjectChecker,
|
||||
} from '../features/private-project/createPrivateProjectChecker';
|
||||
import {
|
||||
createFakeGetActiveUsers,
|
||||
createGetActiveUsers,
|
||||
} from '../features/instance-stats/getActiveUsers';
|
||||
|
||||
// TODO: will be moved to scheduler feature directory
|
||||
export const scheduleServices = async (
|
||||
@ -262,6 +266,7 @@ export const createServices = (
|
||||
stores,
|
||||
config,
|
||||
versionService,
|
||||
db ? createGetActiveUsers(db) : createFakeGetActiveUsers(),
|
||||
);
|
||||
|
||||
const schedulerService = new SchedulerService(config.getLogger);
|
||||
|
@ -33,7 +33,7 @@ import EdgeService from '../services/edge-service';
|
||||
import PatService from '../services/pat-service';
|
||||
import { PublicSignupTokenService } from '../services/public-signup-token-service';
|
||||
import { LastSeenService } from '../services/client-metrics/last-seen-service';
|
||||
import { InstanceStatsService } from '../services/instance-stats-service';
|
||||
import { InstanceStatsService } from '../features/instance-stats/instance-stats-service';
|
||||
import { FavoritesService } from '../services/favorites-service';
|
||||
import MaintenanceService from '../services/maintenance-service';
|
||||
import { AccountService } from '../services/account-service';
|
||||
|
@ -19,12 +19,6 @@ export interface IUserUpdateFields {
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface IActiveUsers {
|
||||
last7: number;
|
||||
last30: number;
|
||||
last90: number;
|
||||
}
|
||||
|
||||
export interface IUserStore extends Store<IUser, number> {
|
||||
update(id: number, fields: IUserUpdateFields): Promise<IUser>;
|
||||
insert(user: ICreateUser): Promise<IUser>;
|
||||
@ -38,5 +32,4 @@ export interface IUserStore extends Store<IUser, number> {
|
||||
incLoginAttempts(user: IUser): Promise<void>;
|
||||
successfullyLogin(user: IUser): Promise<void>;
|
||||
count(): Promise<number>;
|
||||
getActiveUsersCount(): Promise<IActiveUsers>;
|
||||
}
|
||||
|
21
src/test/fixtures/fake-user-store.ts
vendored
21
src/test/fixtures/fake-user-store.ts
vendored
@ -1,6 +1,5 @@
|
||||
import User, { IUser } from '../../lib/types/user';
|
||||
import {
|
||||
IActiveUsers,
|
||||
ICreateUser,
|
||||
IUserLookup,
|
||||
IUserStore,
|
||||
@ -47,26 +46,6 @@ class UserStoreMock implements IUserStore {
|
||||
return this.data.find((u) => u.id === key);
|
||||
}
|
||||
|
||||
async getActiveUsersCount(): Promise<IActiveUsers> {
|
||||
return Promise.resolve({
|
||||
last7: this.data.filter(
|
||||
(u) =>
|
||||
u.seenAt &&
|
||||
u.seenAt > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
).length,
|
||||
last30: this.data.filter(
|
||||
(u) =>
|
||||
u.seenAt &&
|
||||
u.seenAt > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
).length,
|
||||
last90: this.data.filter(
|
||||
(u) =>
|
||||
u.seenAt &&
|
||||
u.seenAt > new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
|
||||
).length,
|
||||
});
|
||||
}
|
||||
|
||||
async insert(user: User): Promise<User> {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
user.id = this.idSeq;
|
||||
|
Loading…
Reference in New Issue
Block a user