Skip to content

Commit

Permalink
Dashboard Metrics (#361)
Browse files Browse the repository at this point in the history
Co-authored-by: Konsti Wohlwend <[email protected]>
  • Loading branch information
TheCactusBlue and N2D4 authored Dec 22, 2024
1 parent ac0d657 commit cd35e8c
Show file tree
Hide file tree
Showing 29 changed files with 78,806 additions and 36 deletions.
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"codegen",
"Crudl",
"ctsx",
"datapoints",
"deindent",
"EAUTH",
"EDNS",
Expand All @@ -24,6 +25,7 @@
"hookform",
"hostable",
"INBUCKET",
"ipcountry",
"Jwks",
"JWTs",
"katex",
Expand Down
7 changes: 4 additions & 3 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"dev": "concurrently -n \"dev,codegen,prisma-studio\" -k \"next dev --turbopack --port 8102\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\"",
"build": "pnpm run codegen && next build",
"docker-build": "pnpm run codegen && next build --experimental-build-mode compile",
"self-host-seed-script": "tsup --config prisma/tsup.config.ts",
"build-self-host-seed-script": "tsup --config prisma/tsup.config.ts",
"analyze-bundle": "ANALYZE_BUNDLE=1 pnpm run build",
"start": "next start --port 8102",
"codegen-prisma": "pnpm run prisma generate",
Expand All @@ -24,10 +24,11 @@
"lint": "next lint",
"watch-docs": "pnpm run with-env tsx watch --clear-screen=false scripts/generate-docs.ts",
"generate-docs": "pnpm run with-env tsx scripts/generate-docs.ts",
"generate-keys": "pnpm run with-env tsx scripts/generate-keys.ts"
"generate-keys": "pnpm run with-env tsx scripts/generate-keys.ts",
"db-seed-script": "pnpm run with-env tsx prisma/seed.ts"
},
"prisma": {
"seed": "pnpm run with-env tsx prisma/seed.ts"
"seed": "pnpm run db-seed-script"
},
"dependencies": {
"@next/bundle-analyzer": "15.0.3",
Expand Down
23 changes: 19 additions & 4 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,16 +230,31 @@ async function seed() {
throw new Error('GitHub OAuth provider config not found');
}

await tx.projectUserOAuthAccount.create({
data: {
const githubAccount = await tx.projectUserOAuthAccount.findFirst({
where: {
projectId: 'internal',
projectConfigId: (internalProject as any).configId,
projectUserId: newUser.projectUserId,
oauthProviderConfigId: 'github',
providerAccountId: adminGithubId
providerAccountId: adminGithubId,
}
});

if (githubAccount) {
console.log(`GitHub account already exists, skipping creation`);
} else {
await tx.projectUserOAuthAccount.create({
data: {
projectId: 'internal',
projectConfigId: (internalProject as any).configId,
projectUserId: newUser.projectUserId,
oauthProviderConfigId: 'github',
providerAccountId: adminGithubId
}
});

console.log(`Added GitHub account for admin user`);
}

await tx.authMethod.create({
data: {
projectId: 'internal',
Expand Down
252 changes: 252 additions & 0 deletions apps/backend/src/app/api/v1/internal/metrics/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import yup from 'yup';
import { usersCrudHandlers } from "../../users/crud";

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 LatestEventWithCountryCode AS (
SELECT DISTINCT ON ("userId")
"data"->'userId' AS "userId",
"countryCode",
"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}
AND "countryCode" IS NOT NULL
ORDER BY "userId", "eventStartedAt" DESC
)
SELECT "countryCode", COUNT("userId") AS "userCount"
FROM LatestEventWithCountryCode
GROUP BY "countryCode"
ORDER BY "userCount" DESC;
`;
console.log("AAAAAAAAAA", a);

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.toISOString().split('T')[0],
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.toISOString().split('T')[0],
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,
CASE WHEN pam IS NOT NULL THEN 'password' ELSE NULL END,
CASE WHEN pkm IS NOT NULL THEN 'passkey' ELSE NULL END,
CASE WHEN oam IS NOT NULL THEN 'otp' ELSE NULL END,
'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
LEFT JOIN "PasswordAuthMethod" pam ON method.id = pam."authMethodId"
LEFT JOIN "PasskeyAuthMethod" pkm ON method.id = pkm."authMethodId"
LEFT JOIN "OtpAuthMethod" oam ON method.id = oam."authMethodId"
WHERE method."projectId" = ${projectId})
SELECT LOWER("method") AS method, COUNT(id)::int AS "count" FROM tab
GROUP BY "method"
`;
}

async function loadRecentlyActiveUsers(project: ProjectsCrud["Admin"]["Read"]): Promise<UsersCrud["Admin"]["Read"][]> {
// use the Events table to get the most recent activity
const events = await prismaClient.$queryRaw<{ data: any, eventStartedAt: Date }[]>`
WITH RankedEvents AS (
SELECT
"data", "eventStartedAt",
ROW_NUMBER() OVER (
PARTITION BY "data"->>'userId'
ORDER BY "eventStartedAt" DESC
) as rn
FROM "Event"
WHERE "data"->>'projectId' = ${project.id}
AND '$user-activity' = ANY("systemEventTypeIds"::text[])
)
SELECT "data", "eventStartedAt"
FROM RankedEvents
WHERE rn = 1
ORDER BY "eventStartedAt" DESC
LIMIT 5
`;
const userObjects: UsersCrud["Admin"]["Read"][] = [];
for (const event of events) {
let user;
try {
user = await usersCrudHandlers.adminRead({
project,
user_id: event.data.userId,
allowedErrorTypes: [
KnownErrors.UserNotFound,
],
});
} catch (e) {
if (e instanceof KnownErrors.UserNotFound) {
// user probably deleted their account, skip
continue;
}
throw e;
}
userObjects.push(user);
}
return userObjects;
}

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(),
recently_registered: yupMixed().defined(),
recently_active: yupMixed().defined(),
login_methods: yupMixed().defined(),
}).defined(),
}),
handler: async (req) => {
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),
(await usersCrudHandlers.adminList({
project: req.auth.project,
query: {
order_by: 'signed_up_at',
desc: true,
limit: 5,
},
allowedErrorTypes: [
KnownErrors.UserNotFound,
],
})).items,
loadRecentlyActiveUsers(req.auth.project),
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: recentlyRegistered,
recently_active: recentlyActive,
login_methods: loginMethods,
}
};
},
});

3 changes: 3 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@next/mdx": "^14",
"@node-oauth/oauth2-server": "^5.1.0",
"@radix-ui/react-icons": "^1.3.1",
"@react-hook/resize-observer": "^2.0.2",
"@sentry/nextjs": "^8.40.0",
"@stackframe/stack": "workspace:*",
"@stackframe/stack-emails": "workspace:*",
Expand All @@ -47,8 +48,10 @@
"posthog-js": "^1.149.1",
"react": "^18.2",
"react-dom": "^18",
"react-globe.gl": "^2.28.2",
"react-hook-form": "^7.53.1",
"react-icons": "^5.0.1",
"recharts": "^2.14.1",
"rehype-katex": "^7",
"remark-gfm": "^4",
"remark-heading-id": "^1.0.1",
Expand Down

Large diffs are not rendered by default.

Loading

0 comments on commit cd35e8c

Please sign in to comment.