mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Move internal metrics queries to ClickHouse replica (#1463)
## Summary - Move `loadTotalUsers`, `loadAuthOverview`, and `loadRecentlyActiveUsers` off direct Postgres queries to read from the ClickHouse `analytics_internal` tables. - Route the remaining `projectUser.findMany` reads in `loadActiveUsersByCountry` and `loadRecentlyActiveUsers` through `$replica()`. - `loadRecentlyActiveUsers` falls back to an empty list on ClickHouse query failure (captured via `captureError`) rather than failing the whole metrics endpoint. ## Test plan - [ ] Hit the internal metrics endpoint on a tenancy with users/teams and confirm totals, daily series, and recently-active users match the previous Postgres-backed numbers. - [ ] Verify the 30-day daily-users series fills zero-activity days correctly. - [ ] Simulate a ClickHouse failure for the recently-active query and confirm the endpoint still responds with the rest of the payload. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes & Improvements** * Improved metrics aggregation for more consistent reporting. * More accurate active-user and total-user time series with missing days zero-filled. * Authentication overview updated with clearer counts for verified, unverified, and anonymous users. * Performance improvements: recently-active and overview calculations run more efficiently and in parallel. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1463?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
5edccc322c
commit
e3bd5a6638
@ -226,7 +226,7 @@ async function loadActiveUsersByCountry(
|
||||
if (allIds.size === 0) return {};
|
||||
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const dbUsers = await prisma.projectUser.findMany({
|
||||
const dbUsers = await prisma.$replica().projectUser.findMany({
|
||||
where: {
|
||||
tenancyId: tenancy.id,
|
||||
projectUserId: { in: Array.from(allIds) },
|
||||
@ -320,32 +320,46 @@ async function loadLiveUsersCount(
|
||||
}
|
||||
|
||||
async function loadTotalUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise<DataPoints> {
|
||||
const schema = await getPrismaSchemaForTenancy(tenancy);
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
return (await prisma.$replica().$queryRaw<{ date: Date, dailyUsers: bigint, cumUsers: bigint }[]>`
|
||||
WITH date_series AS (
|
||||
SELECT GENERATE_SERIES(
|
||||
${now}::date - INTERVAL '30 days',
|
||||
${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 ${sqlQuoteIdent(schema)}."ProjectUser" pu
|
||||
ON DATE(COALESCE(pu."signedUpAt", pu."createdAt")) = ds.registration_day
|
||||
AND pu."tenancyId" = ${tenancy.id}::UUID
|
||||
AND (${includeAnonymous} OR pu."isAnonymous" = false)
|
||||
GROUP BY ds.registration_day
|
||||
ORDER BY ds.registration_day
|
||||
`).map((x) => ({
|
||||
date: x.date.toISOString().split('T')[0],
|
||||
activity: Number(x.dailyUsers),
|
||||
}));
|
||||
const { since, untilExclusive } = getMetricsWindowBounds(now);
|
||||
const clickhouseClient = getClickhouseAdminClientForMetrics();
|
||||
|
||||
const result = await clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
toDate(signed_up_at) AS day,
|
||||
count() AS daily_users
|
||||
FROM analytics_internal.users FINAL
|
||||
WHERE project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND sync_is_deleted = 0
|
||||
AND signed_up_at >= {since:DateTime}
|
||||
AND signed_up_at < {untilExclusive:DateTime}
|
||||
AND ({includeAnonymous:UInt8} = 1 OR is_anonymous = 0)
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
`,
|
||||
query_params: {
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
const rows = await result.json() as { day: string, daily_users: string | number }[];
|
||||
|
||||
const countByDay = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
countByDay.set(row.day.split('T')[0], Number(row.daily_users));
|
||||
}
|
||||
|
||||
const out: DataPoints = [];
|
||||
for (let i = 0; i <= METRICS_WINDOW_DAYS; i++) {
|
||||
const dayKey = new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0];
|
||||
out.push({ date: dayKey, activity: countByDay.get(dayKey) ?? 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false) {
|
||||
@ -538,7 +552,7 @@ async function loadLoginMethods(tenancy: Tenancy): Promise<{ method: string, cou
|
||||
|
||||
async function loadRecentlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolean = false): Promise<UsersCrud["Admin"]["Read"][]> {
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const dbUsers = await prisma.projectUser.findMany({
|
||||
const dbUsers = await prisma.$replica().projectUser.findMany({
|
||||
where: {
|
||||
tenancyId: tenancy.id,
|
||||
...(!includeAnonymous ? { isAnonymous: false } : {}),
|
||||
@ -1372,48 +1386,69 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
// ── Auth Extra Aggregates ────────────────────────────────────────────────────
|
||||
|
||||
async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now: Date) {
|
||||
const schema = await getPrismaSchemaForTenancy(tenancy);
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const clickhouseClient = getClickhouseAdminClientForMetrics();
|
||||
|
||||
const [counts, dailyActiveUsersSplit, dailyActiveTeamsSplit, mau] = await Promise.all([
|
||||
prisma.$replica().$queryRaw<[{
|
||||
total_users: number,
|
||||
verified_non_anonymous_users: number,
|
||||
anonymous_users: number,
|
||||
total_teams: number,
|
||||
}]>`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${sqlQuoteIdent(schema)}."ProjectUser"
|
||||
WHERE "tenancyId" = ${tenancy.id}::UUID) AS total_users,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${sqlQuoteIdent(schema)}."ProjectUser" pu
|
||||
WHERE pu."tenancyId" = ${tenancy.id}::UUID
|
||||
AND pu."isAnonymous" = false
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ${sqlQuoteIdent(schema)}."ContactChannel" cc
|
||||
WHERE cc."tenancyId" = pu."tenancyId"
|
||||
AND cc."projectUserId" = pu."projectUserId"
|
||||
AND cc."type" = 'EMAIL'::"ContactChannelType"
|
||||
AND cc."isVerified" = true
|
||||
)) AS verified_non_anonymous_users,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${sqlQuoteIdent(schema)}."ProjectUser"
|
||||
WHERE "tenancyId" = ${tenancy.id}::UUID
|
||||
AND "isAnonymous" = true) AS anonymous_users,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${sqlQuoteIdent(schema)}."Team"
|
||||
WHERE "tenancyId" = ${tenancy.id}::UUID) AS total_teams
|
||||
`,
|
||||
const [usersRow, teamsRow, dailyActiveUsersSplit, dailyActiveTeamsSplit, mau] = await Promise.all([
|
||||
clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
countIf(sync_is_deleted = 0) AS total_users,
|
||||
countIf(sync_is_deleted = 0 AND is_anonymous = 1) AS anonymous_users,
|
||||
countIf(
|
||||
sync_is_deleted = 0
|
||||
AND is_anonymous = 0
|
||||
AND id IN (
|
||||
SELECT user_id
|
||||
FROM analytics_internal.contact_channels FINAL
|
||||
WHERE project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND sync_is_deleted = 0
|
||||
AND type = 'EMAIL'
|
||||
AND is_verified = 1
|
||||
)
|
||||
) AS verified_non_anonymous_users
|
||||
FROM analytics_internal.users FINAL
|
||||
WHERE project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
`,
|
||||
query_params: {
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}).then(async (r) => {
|
||||
const rows = await r.json() as [{
|
||||
total_users: string | number,
|
||||
anonymous_users: string | number,
|
||||
verified_non_anonymous_users: string | number,
|
||||
}];
|
||||
return rows[0];
|
||||
}),
|
||||
clickhouseClient.query({
|
||||
query: `
|
||||
SELECT countIf(sync_is_deleted = 0) AS total_teams
|
||||
FROM analytics_internal.teams FINAL
|
||||
WHERE project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
`,
|
||||
query_params: {
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}).then(async (r) => {
|
||||
const rows = await r.json() as [{ total_teams: string | number }];
|
||||
return rows[0];
|
||||
}),
|
||||
loadDailyActiveUsersSplit(tenancy, now, includeAnonymous),
|
||||
loadDailyActiveTeamsSplit(tenancy, now),
|
||||
loadMonthlyActiveUsers(tenancy, now, includeAnonymous),
|
||||
]);
|
||||
|
||||
const totalUsers = Number(counts[0].total_users);
|
||||
const verifiedNonAnonymousUsers = Number(counts[0].verified_non_anonymous_users);
|
||||
const anonymousUsers = Number(counts[0].anonymous_users);
|
||||
const totalTeams = Number(counts[0].total_teams);
|
||||
const totalUsers = Number(usersRow.total_users);
|
||||
const verifiedNonAnonymousUsers = Number(usersRow.verified_non_anonymous_users);
|
||||
const anonymousUsers = Number(usersRow.anonymous_users);
|
||||
const totalTeams = Number(teamsRow.total_teams);
|
||||
const nonAnonymousTotal = totalUsers - anonymousUsers;
|
||||
// total_users_filtered respects the includeAnonymous query flag so the
|
||||
// handler can use it directly without a separate count round trip.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user