-
Notifications
You must be signed in to change notification settings - Fork 298
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Dashboard Metrics #361
Dashboard Metrics #361
Changes from 90 commits
cea70e4
30518b6
1440c19
d9ac2d2
6c3d032
014597f
c7e4768
3493b05
ec8c353
2ec6ac4
0858014
9eba27d
d585e12
42b672a
e32a22e
05cf6bc
8a36b88
2fdeb02
cda38fe
00452e5
823634a
7262204
c58001b
e02d390
dad701e
2464be5
a6547a1
85548a2
5738d9b
606c09d
f30de21
e8768b7
396d148
019c05a
78f8eaa
00b6310
2999efa
7343dbd
1098a5b
77be2ad
196dbd8
0aea48d
2fa1377
33b5acb
164a900
92c1f99
585553f
61b0d4f
3002921
2b4da7e
59ed31b
6578f95
adc70f8
ca41242
75ab6c2
644f8dd
1de0307
3acf8ae
9093079
e21b971
19c4b5f
374797d
455f189
51c5480
be3de75
ee08381
e1f765c
a821052
57e1fe0
0743f4c
532e71c
a2afa26
d65dcc1
8c2d54f
f8f8d7f
7beadfe
de0c79a
f96264f
d96b194
992071d
5bc8374
195448b
23d496b
9096aae
599ec56
a31a848
a439598
9c40b9e
1a558c7
2b1c817
75344e1
03b69e0
65b88a8
80591b2
7874506
1cb0c70
3aa483c
c99efd4
90599ba
f201130
6f9083f
335715d
e5bb50a
04ef4e7
b757a32
1f9a223
a6ee07d
d944062
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,6 +21,7 @@ | |
"hookform", | ||
"hostable", | ||
"INBUCKET", | ||
"ipcountry", | ||
"Jwks", | ||
"JWTs", | ||
"katex", | ||
|
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
@@ -0,0 +1,226 @@ | ||||
import { prismaClient } from "@/prisma-client"; | ||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; | ||||
import { ContactChannel, ProjectUser } from "@prisma/client"; | ||||
import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; | ||||
import yup from 'yup'; | ||||
|
||||
type DataPoints = yup.InferType<typeof DataPointsSchema>; | ||||
|
||||
const DataPointsSchema = yupArray(yupObject({ | ||||
date: yupString().defined(), | ||||
activity: yupNumber().defined(), | ||||
}).defined()).defined(); | ||||
|
||||
|
||||
async function loadUsersByCountry(projectId: string): Promise<Record<string, number>> { | ||||
const a = await prismaClient.$queryRaw<{countryCode: string|null, userCount: bigint}[]>` | ||||
WITH LatestEvent AS ( | ||||
SELECT "data"->'userId' AS "userId", | ||||
"countryCode", MAX("eventStartedAt") AS latest_timestamp | ||||
FROM "Event" | ||||
LEFT JOIN "EventIpInfo" eip | ||||
ON "Event"."endUserIpInfoGuessId" = eip.id | ||||
WHERE '$user-activity' = ANY("systemEventTypeIds"::text[]) | ||||
AND "data"->>'projectId' = ${projectId} | ||||
GROUP BY "userId", "countryCode" | ||||
) | ||||
SELECT "countryCode", COUNT(DISTINCT "userId") AS "userCount" | ||||
FROM LatestEvent | ||||
GROUP BY "countryCode" | ||||
ORDER BY "userCount" DESC; | ||||
`; | ||||
|
||||
const rec = Object.fromEntries( | ||||
a.map(({ userCount, countryCode }) => [countryCode, Number(userCount)]) | ||||
.filter(([countryCode, userCount]) => countryCode) | ||||
); | ||||
return rec; | ||||
} | ||||
|
||||
async function loadTotalUsers(projectId: string, now: Date): Promise<DataPoints> { | ||||
return (await prismaClient.$queryRaw<{date: Date, dailyUsers: bigint, cumUsers: bigint}[]>` | ||||
WITH date_series AS ( | ||||
SELECT GENERATE_SERIES( | ||||
${now}::date - INTERVAL '1 month', | ||||
${now}::date, | ||||
'1 day' | ||||
) | ||||
AS registration_day | ||||
) | ||||
SELECT | ||||
ds.registration_day AS "date", | ||||
COALESCE(COUNT(pu."projectUserId"), 0) AS "dailyUsers", | ||||
SUM(COALESCE(COUNT(pu."projectUserId"), 0)) OVER (ORDER BY ds.registration_day) AS "cumUsers" | ||||
FROM date_series ds | ||||
LEFT JOIN "ProjectUser" pu | ||||
ON DATE(pu."createdAt") = ds.registration_day AND pu."projectId" = ${projectId} | ||||
GROUP BY ds.registration_day | ||||
ORDER BY ds.registration_day | ||||
`).map((x) => ({ | ||||
date: x.date.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }), | ||||
activity: Number(x.dailyUsers), | ||||
})); | ||||
} | ||||
|
||||
async function loadDailyActiveUsers(projectId: string, now: Date) { | ||||
const res = await prismaClient.$queryRaw<{day: Date, dau: bigint}[]>` | ||||
WITH date_series AS ( | ||||
SELECT GENERATE_SERIES( | ||||
${now}::date - INTERVAL '1 month', | ||||
${now}::date, | ||||
'1 day' | ||||
) | ||||
AS "day" | ||||
), | ||||
daily_users AS ( | ||||
SELECT | ||||
DATE_TRUNC('day', "eventStartedAt") AS "day", | ||||
COUNT(DISTINCT "data"->'userId') AS "dau" | ||||
FROM "Event" | ||||
WHERE "eventStartedAt" >= ${now} - INTERVAL '1 month' | ||||
AND "eventStartedAt" < ${now} | ||||
AND '$user-activity' = ANY("systemEventTypeIds"::text[]) | ||||
AND "data"->>'projectId' = ${projectId} | ||||
GROUP BY DATE_TRUNC('day', "eventStartedAt") | ||||
) | ||||
SELECT ds."day", COALESCE(du.dau, 0) AS dau | ||||
FROM date_series ds | ||||
LEFT JOIN daily_users du | ||||
ON ds."day" = du."day" | ||||
ORDER BY ds."day" | ||||
`; | ||||
|
||||
return res.map(x => ({ | ||||
date: x.day.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }), | ||||
activity: Number(x.dau), | ||||
})); | ||||
} | ||||
|
||||
async function loadLoginMethods(projectId: string): Promise<{method: string, count: number }[]> { | ||||
return await prismaClient.$queryRaw<{ method: string, count: number }[]>` | ||||
WITH tab AS ( | ||||
SELECT COALESCE(soapc."type"::text, poapc."type"::text, 'other') AS "method", method.id AS id FROM "AuthMethod" method | ||||
LEFT JOIN "OAuthAuthMethod" oaam ON method.id = oaam."authMethodId" | ||||
LEFT JOIN "OAuthProviderConfig" oapc | ||||
ON oaam."projectConfigId" = oapc."projectConfigId" AND oaam."oauthProviderConfigId" = oapc.id | ||||
LEFT JOIN "StandardOAuthProviderConfig" soapc | ||||
ON oapc."projectConfigId" = soapc."projectConfigId" AND oapc.id = soapc.id | ||||
LEFT JOIN "ProxiedOAuthProviderConfig" poapc | ||||
ON oapc."projectConfigId" = poapc."projectConfigId" AND oapc.id = poapc.id | ||||
WHERE method."projectId" = ${projectId}) | ||||
SELECT LOWER("method") AS method, COUNT(id)::int AS "count" FROM tab | ||||
GROUP BY "method" | ||||
`; | ||||
} | ||||
|
||||
function simplifyUsers(users: (ProjectUser & { contactChannels: ContactChannel[] })[]): any { | ||||
return users.map((user) => ({ | ||||
id: user.projectUserId, | ||||
display_name: user.displayName, | ||||
email: user.contactChannels.find(x => x.isPrimary)?.value ?? '-', | ||||
created_at_millis: user.createdAt.getTime(), | ||||
updated_at_millis: user.updatedAt.getTime(), | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we get rid of the updated_at_millis now?
Suggested change
|
||||
})); | ||||
} | ||||
|
||||
async function loadRecentlyActiveUsers(projectId: string): | ||||
Promise<(ProjectUser & { contactChannels: ContactChannel[] })[]> { | ||||
|
||||
// use the Events table to get the most recent activity | ||||
const events = await prismaClient.$queryRaw<{ data: any }[]>` | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using |
||||
WITH RankedEvents AS ( | ||||
SELECT | ||||
"data", | ||||
ROW_NUMBER() OVER ( | ||||
PARTITION BY "data"->>'userId' | ||||
ORDER BY "eventStartedAt" DESC | ||||
) as rn | ||||
FROM "Event" | ||||
WHERE "data"->>'projectId' = ${projectId} | ||||
AND '$user-activity' = ANY("systemEventTypeIds"::text[]) | ||||
) | ||||
SELECT "data" | ||||
FROM RankedEvents | ||||
WHERE rn = 1 | ||||
LIMIT 10 | ||||
`; | ||||
return await prismaClient.projectUser.findMany({ | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you have to return |
||||
take: 10, | ||||
N2D4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
where: { projectId, projectUserId: { in: events.map(x => (x.data as any).userId) } }, | ||||
N2D4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
include: { contactChannels: true }, | ||||
orderBy: [{ | ||||
updatedAt: 'desc' | ||||
}] | ||||
N2D4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
}); | ||||
} | ||||
|
||||
export const GET = createSmartRouteHandler({ | ||||
metadata: { | ||||
hidden: true, | ||||
}, | ||||
request: yupObject({ | ||||
auth: yupObject({ | ||||
type: adminAuthTypeSchema.defined(), | ||||
project: adaptSchema.defined(), | ||||
}), | ||||
}), | ||||
response: yupObject({ | ||||
statusCode: yupNumber().oneOf([200]).defined(), | ||||
bodyType: yupString().oneOf(["json"]).defined(), | ||||
body: yupObject({ | ||||
total_users: yupNumber().integer().defined(), | ||||
daily_users: DataPointsSchema, | ||||
daily_active_users: DataPointsSchema, | ||||
// TODO: Narrow down the types further | ||||
users_by_country: yupMixed().defined(), | ||||
TheCactusBlue marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
recently_registered: yupMixed().defined(), | ||||
recently_active: yupMixed().defined(), | ||||
login_methods: yupMixed().defined(), | ||||
}).defined(), | ||||
}), | ||||
handler: async (req) => { | ||||
N2D4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
const now = new Date(); | ||||
|
||||
const [ | ||||
totalUsers, | ||||
dailyUsers, | ||||
dailyActiveUsers, | ||||
usersByCountry, | ||||
recentlyRegistered, | ||||
recentlyActive, | ||||
loginMethods | ||||
] = await Promise.all([ | ||||
prismaClient.projectUser.count({ | ||||
where: { projectId: req.auth.project.id, }, | ||||
}), | ||||
loadTotalUsers(req.auth.project.id, now), | ||||
loadDailyActiveUsers(req.auth.project.id, now), | ||||
loadUsersByCountry(req.auth.project.id), | ||||
prismaClient.projectUser.findMany({ | ||||
take: 10, | ||||
where: { projectId: req.auth.project.id, }, | ||||
include: { contactChannels: true }, | ||||
orderBy: [{ | ||||
createdAt: 'desc' | ||||
}] | ||||
}), | ||||
loadRecentlyActiveUsers(req.auth.project.id), | ||||
loadLoginMethods(req.auth.project.id), | ||||
] as const); | ||||
|
||||
return { | ||||
statusCode: 200, | ||||
bodyType: "json", | ||||
body: { | ||||
total_users: totalUsers, | ||||
daily_users: dailyUsers, | ||||
daily_active_users: dailyActiveUsers, | ||||
users_by_country: usersByCountry, | ||||
recently_registered: simplifyUsers(recentlyRegistered), | ||||
recently_active: simplifyUsers(recentlyActive), | ||||
login_methods: loginMethods, | ||||
} | ||||
}; | ||||
}, | ||||
}); | ||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid using
any
type. Define a specific return type forsimplifyUsers
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
++