Skip to content

Commit

Permalink
feat: Add active users statistics to metrics (#4674)
Browse files Browse the repository at this point in the history
## 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.
  • Loading branch information
Tymek committed Sep 18, 2023
1 parent 4484615 commit 2c826bd
Show file tree
Hide file tree
Showing 13 changed files with 289 additions and 80 deletions.
25 changes: 0 additions & 25 deletions src/lib/db/user-store.ts
Expand Up @@ -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> {
Expand Down
154 changes: 154 additions & 0 deletions src/lib/features/instance-stats/getActiveUsers.e2e.test.ts
@@ -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 changes: 56 additions & 0 deletions src/lib/features/instance-stats/getActiveUsers.ts
@@ -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;
Expand All @@ -14,6 +15,7 @@ beforeEach(() => {
stores,
config,
versionService,
createFakeGetActiveUsers(),
);

jest.spyOn(instanceStatsService, 'refreshStatsSnapshot');
Expand Down
@@ -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';

Expand All @@ -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 {
Expand Down Expand Up @@ -83,6 +84,8 @@ export class InstanceStatsService {

private appCount?: Partial<{ [key in TimeRange]: number }>;

private getActiveUsers: GetActiveUsers;

constructor(
{
featureToggleStore,
Expand Down Expand Up @@ -114,6 +117,7 @@ export class InstanceStatsService {
>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
versionService: VersionService,
getActiveUsers: GetActiveUsers,
) {
this.strategyStore = strategyStore;
this.userStore = userStore;
Expand All @@ -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> {
Expand Down Expand Up @@ -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(),
Expand Down
11 changes: 9 additions & 2 deletions src/lib/metrics.test.ts
Expand Up @@ -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();
Expand All @@ -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: {
Expand Down

0 comments on commit 2c826bd

Please sign in to comment.