Skip to content

Commit c228e1c

Browse files
committed
Exclude anonymous users from metrics
1 parent 3ab5aa5 commit c228e1c

File tree

6 files changed

+168
-13
lines changed

6 files changed

+168
-13
lines changed

apps/backend/src/app/api/latest/internal/metrics/route.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ async function loadUsersByCountry(tenancy: Tenancy): Promise<Record<string, numb
2727
ON "Event"."endUserIpInfoGuessId" = eip.id
2828
WHERE '$user-activity' = ANY("systemEventTypeIds"::text[])
2929
AND "data"->>'projectId' = ${tenancy.project.id}
30+
AND "data"->>'isAnonymous' != 'true'
3031
AND COALESCE("data"->>'branchId', 'main') = ${tenancy.branchId}
3132
AND "countryCode" IS NOT NULL
3233
ORDER BY "userId", "eventStartedAt" DESC
@@ -62,7 +63,9 @@ async function loadTotalUsers(tenancy: Tenancy, now: Date): Promise<DataPoints>
6263
SUM(COALESCE(COUNT(pu."projectUserId"), 0)) OVER (ORDER BY ds.registration_day) AS "cumUsers"
6364
FROM date_series ds
6465
LEFT JOIN ${sqlQuoteIdent(schema)}."ProjectUser" pu
65-
ON DATE(pu."createdAt") = ds.registration_day AND pu."tenancyId" = ${tenancy.id}::UUID
66+
ON DATE(pu."createdAt") = ds.registration_day
67+
AND pu."tenancyId" = ${tenancy.id}::UUID
68+
AND pu."isAnonymous" = false
6669
GROUP BY ds.registration_day
6770
ORDER BY ds.registration_day
6871
`).map((x) => ({
@@ -84,7 +87,7 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date) {
8487
daily_users AS (
8588
SELECT
8689
DATE_TRUNC('day', "eventStartedAt") AS "day",
87-
COUNT(DISTINCT "data"->'userId') AS "dau"
90+
COUNT(DISTINCT CASE WHEN "data"->>'isAnonymous' = 'false' THEN "data"->'userId' ELSE NULL END) AS "dau"
8891
FROM "Event"
8992
WHERE "eventStartedAt" >= ${now}::date - INTERVAL '30 days'
9093
AND '$user-activity' = ANY("systemEventTypeIds"::text[])
@@ -143,6 +146,7 @@ async function loadRecentlyActiveUsers(tenancy: Tenancy): Promise<UsersCrud["Adm
143146
) as rn
144147
FROM "Event"
145148
WHERE "data"->>'projectId' = ${tenancy.project.id}
149+
AND "data"->>'isAnonymous' != 'true'
146150
AND COALESCE("data"->>'branchId', 'main') = ${tenancy.branchId}
147151
AND '$user-activity' = ANY("systemEventTypeIds"::text[])
148152
)
@@ -214,22 +218,23 @@ export const GET = createSmartRouteHandler({
214218
loginMethods
215219
] = await Promise.all([
216220
prisma.projectUser.count({
217-
where: { tenancyId: req.auth.tenancy.id, },
221+
where: { tenancyId: req.auth.tenancy.id, isAnonymous: false },
218222
}),
219223
loadTotalUsers(req.auth.tenancy, now),
220224
loadDailyActiveUsers(req.auth.tenancy, now),
221225
loadUsersByCountry(req.auth.tenancy),
222-
(await usersCrudHandlers.adminList({
226+
usersCrudHandlers.adminList({
223227
tenancy: req.auth.tenancy,
224228
query: {
225229
order_by: 'signed_up_at',
226230
desc: "true",
227231
limit: 5,
232+
include_anonymous: "false",
228233
},
229234
allowedErrorTypes: [
230235
KnownErrors.UserNotFound,
231236
],
232-
})).items,
237+
}).then(res => res.items),
233238
loadRecentlyActiveUsers(req.auth.tenancy),
234239
loadLoginMethods(req.auth.tenancy),
235240
] as const);

apps/backend/src/lib/events.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import withPostHog from "@/analytics";
22
import { globalPrismaClient } from "@/prisma-client";
33
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
4-
import { urlSchema, yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { urlSchema, yupBoolean, yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
55
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
66
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
77
import { HTTP_METHODS } from "@stackframe/stack-shared/dist/utils/http";
@@ -49,6 +49,8 @@ const UserActivityEventType = {
4949
// old events of this type may not have a branchId field, so we default to the default branch ID
5050
branchId: yupString().defined().default(DEFAULT_BRANCH_ID),
5151
userId: yupString().uuid().defined(),
52+
// old events of this type may not have an isAnonymous field, so we default to false
53+
isAnonymous: yupBoolean().defined().default(false),
5254
}),
5355
inherits: [ProjectActivityEventType],
5456
} as const satisfies SystemEventTypeBase;

apps/backend/src/lib/tokens.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,21 +116,22 @@ export async function generateAccessToken(options: {
116116
userId: string,
117117
refreshTokenId: string,
118118
}) {
119+
const user = await usersCrudHandlers.adminRead({
120+
tenancy: options.tenancy,
121+
user_id: options.userId,
122+
});
123+
119124
await logEvent(
120125
[SystemEventTypes.SessionActivity],
121126
{
122127
projectId: options.tenancy.project.id,
123128
branchId: options.tenancy.branchId,
124129
userId: options.userId,
125130
sessionId: options.refreshTokenId,
131+
isAnonymous: user.is_anonymous,
126132
}
127133
);
128134

129-
const user = await usersCrudHandlers.adminRead({
130-
tenancy: options.tenancy,
131-
user_id: options.userId,
132-
});
133-
134135
return await signJWT({
135136
issuer: getIssuer(options.tenancy.project.id, user.is_anonymous),
136137
audience: getAudience(options.tenancy.project.id, user.is_anonymous),

apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
2-
import { it } from "../../../../helpers";
2+
import { expect } from "vitest";
3+
import { NiceResponse, it } from "../../../../helpers";
34
import { Auth, InternalApiKey, Project, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers";
45

6+
async function ensureAnonymousUsersAreStillExcluded(metricsResponse: NiceResponse) {
7+
for (let i = 0; i < 2; i++) {
8+
await Auth.Anonymous.signUp();
9+
}
10+
await wait(1000);
11+
const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' });
12+
expect(response.body).toEqual(metricsResponse.body);
13+
}
14+
515
it("should return metrics data", async ({ expect }) => {
616
await Project.createAndSwitch({
717
config: {
@@ -13,6 +23,8 @@ it("should return metrics data", async ({ expect }) => {
1323

1424
const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' });
1525
expect(response).toMatchSnapshot(`metrics_result_no_users`);
26+
27+
await ensureAnonymousUsersAreStillExcluded(response);
1628
});
1729

1830
it("should return metrics data with users", async ({ expect }) => {
@@ -54,6 +66,8 @@ it("should return metrics data with users", async ({ expect }) => {
5466

5567
const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' });
5668
expect(response).toMatchSnapshot();
69+
70+
await ensureAnonymousUsersAreStillExcluded(response);
5771
}, {
5872
timeout: 120_000,
5973
});
@@ -87,4 +101,134 @@ it("should not work for non-admins", async ({ expect }) => {
87101
},
88102
}
89103
`);
104+
105+
await ensureAnonymousUsersAreStillExcluded(response);
106+
});
107+
108+
it("should exclude anonymous users from metrics", async ({ expect }) => {
109+
await Project.createAndSwitch({
110+
config: {
111+
magic_link_enabled: true,
112+
}
113+
});
114+
115+
await InternalApiKey.createAndSetProjectKeys();
116+
117+
// Create 1 regular user
118+
backendContext.set({ mailbox: createMailbox(), ipData: { country: "US", ipAddress: "127.0.0.1", city: "New York", region: "NY", latitude: 40.7128, longitude: -74.0060, tzIdentifier: "America/New_York" } });
119+
await Auth.Otp.signIn();
120+
121+
// Store metrics so we can compare them later
122+
const beforeMetrics = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' });
123+
124+
// Create 2 anonymous users
125+
for (let i = 0; i < 2; i++) {
126+
await Auth.Anonymous.signUp();
127+
}
128+
129+
await wait(1000); // the event log is async, so let's give it some time to be written to the DB
130+
131+
const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' });
132+
expect(beforeMetrics.body).toEqual(response.body);
133+
134+
// Verify that total_users only counts the 1 regular user, not the anonymous ones
135+
expect(response.body.total_users).toBe(1);
136+
137+
// Verify anonymous users don't appear in recently_registered
138+
expect(response.body.recently_registered.length).toBe(1);
139+
expect(response.body.recently_registered.every((user: any) => !user.is_anonymous)).toBe(true);
140+
141+
// Verify anonymous users don't appear in recently_active
142+
expect(response.body.recently_active.every((user: any) => !user.is_anonymous)).toBe(true);
143+
144+
// Verify anonymous users aren't counted in daily_users
145+
const lastDayUsers = response.body.daily_users[response.body.daily_users.length - 1];
146+
expect(lastDayUsers.activity).toBe(1);
147+
148+
// Verify users_by_country only includes regular users
149+
expect(response.body.users_by_country["US"]).toBe(1);
150+
151+
await ensureAnonymousUsersAreStillExcluded(response);
152+
});
153+
154+
it("should handle anonymous users with activity correctly", async ({ expect }) => {
155+
await Project.createAndSwitch({
156+
config: {
157+
magic_link_enabled: true,
158+
}
159+
});
160+
161+
await InternalApiKey.createAndSetProjectKeys();
162+
163+
// Create 1 regular user with activity
164+
const regularMailbox = createMailbox();
165+
backendContext.set({ mailbox: regularMailbox, ipData: { country: "CA", ipAddress: "127.0.0.1", city: "Toronto", region: "ON", latitude: 43.6532, longitude: -79.3832, tzIdentifier: "America/Toronto" } });
166+
await Auth.Otp.signIn();
167+
168+
// Generate some activity for regular user
169+
await niceBackendFetch("/api/v1/users/me", { accessType: 'client' });
170+
171+
// Create 3 anonymous users with activity
172+
for (let i = 0; i < 3; i++) {
173+
await Auth.Anonymous.signUp();
174+
}
175+
176+
await wait(3000); // the event log is async, so let's give it some time to be written to the DB
177+
178+
const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' });
179+
180+
// Should only count 1 regular user
181+
expect(response.body.total_users).toBe(1);
182+
183+
// Daily active users should only count regular users
184+
const todayDAU = response.body.daily_active_users[response.body.daily_active_users.length - 1];
185+
expect(todayDAU.activity).toBe(1);
186+
187+
// Users by country should only count regular users
188+
expect(response.body.users_by_country["CA"]).toBe(1);
189+
expect(response.body.users_by_country["US"]).toBeUndefined();
190+
191+
await ensureAnonymousUsersAreStillExcluded(response);
192+
});
193+
194+
it("should handle mixed auth methods excluding anonymous users", async ({ expect }) => {
195+
await Project.createAndSwitch({
196+
config: {
197+
magic_link_enabled: true,
198+
credential_enabled: true,
199+
}
200+
});
201+
202+
await InternalApiKey.createAndSetProjectKeys();
203+
204+
// Create users with different auth methods
205+
const regularMailbox = createMailbox();
206+
207+
// Regular user with OTP
208+
backendContext.set({ mailbox: regularMailbox });
209+
await Auth.Otp.signIn();
210+
211+
// Regular user with password
212+
const passwordMailbox = createMailbox();
213+
backendContext.set({ mailbox: passwordMailbox });
214+
await Auth.Password.signUpWithEmail({ password: "test1234" });
215+
216+
// Anonymous users (should not be counted)
217+
for (let i = 0; i < 5; i++) {
218+
await Auth.Anonymous.signUp();
219+
}
220+
221+
await wait(3000);
222+
223+
const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' });
224+
225+
// Should only count 2 regular users
226+
expect(response.body.total_users).toBe(2);
227+
228+
// Login methods should only count regular users' methods
229+
const loginMethods = response.body.login_methods;
230+
const totalMethodCount = loginMethods.reduce((sum: number, method: any) => sum + method.count, 0);
231+
expect(totalMethodCount).toBe(2); // 1 OTP + 1 password, no anonymous
232+
233+
await ensureAnonymousUsersAreStillExcluded(response);
90234
});

apps/e2e/tests/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export async function niceFetch(url: string | URL, options?: NiceRequestInit): P
182182
let body;
183183
if (fetchRes.headers.get("content-type")?.includes("application/json")) {
184184
body = await fetchRes.json();
185-
} else if (fetchRes.headers.get("content-type")?.includes("text")) {
185+
} else if (fetchRes.headers.get("content-type")?.startsWith("text/")) {
186186
body = await fetchRes.text();
187187
} else {
188188
body = await fetchRes.arrayBuffer();

packages/stack-shared/src/utils/strings.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,9 @@ export function nicify(
575575
if (ArrayBuffer.isView(value)) {
576576
return `${value.constructor.name}([${value.toString()}])`;
577577
}
578+
if (value instanceof ArrayBuffer) {
579+
return `ArrayBuffer [${new Uint8Array(value).toString()}]`;
580+
}
578581
if (value instanceof Error) {
579582
let stack = value.stack ?? "";
580583
const toString = value.toString();

0 commit comments

Comments
 (0)