Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
13 changed files
with
289 additions
and
80 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
154 changes: 154 additions & 0 deletions
154
src/lib/features/instance-stats/getActiveUsers.e2e.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.