Skip to content

Commit 8fd5b13

Browse files
committed
TokenRefreshEventType
1 parent 68cc025 commit 8fd5b13

6 files changed

Lines changed: 114 additions & 63 deletions

File tree

apps/backend/prisma/migrations/20260201240000_event_created_at_index/migration.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
-- SINGLE_STATEMENT_SENTINEL
33
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
44
-- Add index on createdAt for efficient range queries (used by ClickHouse migration and similar count queries).
5-
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_event_created_at ON "Event" ("createdAt");
5+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "Event_createdAt_idx" ON /* SCHEMA_NAME_SENTINEL */."Event" ("createdAt");
66

77
-- SPLIT_STATEMENT_SENTINEL
88
-- SINGLE_STATEMENT_SENTINEL
99
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
1010
-- Add composite index (createdAt, id) for cursor-based pagination queries.
11-
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_event_created_at_id ON "Event" ("createdAt", "id");
11+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "Event_createdAt_id_idx" ON /* SCHEMA_NAME_SENTINEL */."Event" ("createdAt", "id");

apps/backend/scripts/clickhouse-migrations.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,8 @@ CREATE TABLE IF NOT EXISTS analytics_internal.events (
3535
data JSON,
3636
project_id String,
3737
branch_id String,
38-
user_id String,
39-
team_id String,
40-
refresh_token_id String,
41-
is_anonymous Boolean,
42-
session_id String,
43-
ip_address String,
38+
user_id Nullable(String),
39+
team_id Nullable(String),
4440
created_at DateTime64(3, 'UTC') DEFAULT now64(3)
4541
)
4642
ENGINE MergeTree

apps/backend/src/app/api/latest/internal/clickhouse/migrate-events/route.tsx

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const parseMillisOrThrow = (value: number | undefined, field: string) => {
2222
return parsed;
2323
};
2424

25-
const createClickhouseRows = (event: {
25+
const createClickhouseRow = (event: {
2626
id: string,
2727
systemEventTypeIds: string[],
2828
data: any,
@@ -39,24 +39,18 @@ const createClickhouseRows = (event: {
3939
};
4040
const projectId = typeof dataRecord.projectId === "string" ? dataRecord.projectId : "";
4141
const branchId = DEFAULT_BRANCH_ID;
42-
const userId = typeof dataRecord.userId === "string" ? dataRecord.userId : "";
43-
const teamId = typeof dataRecord.teamId === "string" ? dataRecord.teamId : "";
44-
const sessionId = typeof dataRecord.sessionId === "string" ? dataRecord.sessionId : "";
45-
const isAnonymous = typeof dataRecord.isAnonymous === "boolean" ? dataRecord.isAnonymous : false;
42+
const userId = typeof dataRecord.userId === "string" && dataRecord.userId ? dataRecord.userId : null;
4643

47-
const eventTypes = [...new Set(event.systemEventTypeIds)];
48-
49-
return eventTypes.map(eventType => ({
50-
event_type: eventType,
44+
// Translate $session-activity to $token-refresh
45+
return {
46+
event_type: '$token-refresh',
5147
event_at: event.eventEndedAt,
5248
data: clickhouseEventData,
5349
project_id: projectId,
5450
branch_id: branchId,
5551
user_id: userId,
56-
team_id: teamId,
57-
session_id: sessionId,
58-
is_anonymous: isAnonymous,
59-
}));
52+
team_id: null,
53+
};
6054
};
6155

6256
export const POST = createSmartRouteHandler({
@@ -111,6 +105,10 @@ export const POST = createSmartRouteHandler({
111105
gte: minCreatedAt,
112106
lt: maxCreatedAt,
113107
},
108+
// Only migrate $session-activity events (translated to $token-refresh in ClickHouse)
109+
systemEventTypeIds: {
110+
has: '$session-activity',
111+
},
114112
};
115113

116114
const cursorFilter: Prisma.EventWhereInput | undefined = (cursorCreatedAt && cursorId) ? {
@@ -141,22 +139,19 @@ export const POST = createSmartRouteHandler({
141139
throw new StatusError(StatusError.ServiceUnavailable, "ClickHouse is not configured");
142140
}
143141
const clickhouseClient = getClickhouseAdminClient();
144-
const rowsByEvent = events.map(createClickhouseRows);
145-
const rowsToInsert = rowsByEvent.flat();
146-
migratedEvents = rowsByEvent.reduce((acc, rows) => acc + (rows.length ? 1 : 0), 0);
142+
const rowsToInsert = events.map(createClickhouseRow);
143+
migratedEvents = events.length;
147144

148-
if (rowsToInsert.length) {
149-
await clickhouseClient.insert({
150-
table: "analytics_internal.events",
151-
values: rowsToInsert,
152-
format: "JSONEachRow",
153-
clickhouse_settings: {
154-
date_time_input_format: "best_effort",
155-
async_insert: 1,
156-
},
157-
});
158-
insertedRows = rowsToInsert.length;
159-
}
145+
await clickhouseClient.insert({
146+
table: "analytics_internal.events",
147+
values: rowsToInsert,
148+
format: "JSONEachRow",
149+
clickhouse_settings: {
150+
date_time_input_format: "best_effort",
151+
async_insert: 1,
152+
},
153+
});
154+
insertedRows = rowsToInsert.length;
160155
}
161156

162157
const lastEvent = events.at(-1);

apps/backend/src/lib/events.tsx

Lines changed: 59 additions & 12 deletions
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, yupBoolean, yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { urlSchema, yupBoolean, yupMixed, yupNumber, 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";
@@ -13,6 +13,42 @@ import { getClickhouseAdminClient, isClickhouseConfigured } from "./clickhouse";
1313
import { getEndUserInfo } from "./end-users";
1414
import { DEFAULT_BRANCH_ID } from "./tenancies";
1515

16+
export const endUserIpInfoSchema = yupObject({
17+
ip: yupString().defined(),
18+
isTrusted: yupBoolean().defined(),
19+
countryCode: yupString().optional(),
20+
regionCode: yupString().optional(),
21+
cityName: yupString().optional(),
22+
latitude: yupNumber().optional(),
23+
longitude: yupNumber().optional(),
24+
tzIdentifier: yupString().optional(),
25+
});
26+
27+
export type EndUserIpInfo = yup.InferType<typeof endUserIpInfoSchema>;
28+
29+
/**
30+
* Extracts the end user IP info from the current request.
31+
* Must be called before any async operations as it uses dynamic APIs.
32+
*/
33+
export async function getEndUserIpInfoForEvent(): Promise<EndUserIpInfo | null> {
34+
const endUserInfo = await getEndUserInfo();
35+
if (!endUserInfo) {
36+
return null;
37+
}
38+
39+
const info = endUserInfo.maybeSpoofed ? endUserInfo.spoofedInfo : endUserInfo.exactInfo;
40+
return {
41+
ip: info.ip,
42+
isTrusted: !endUserInfo.maybeSpoofed,
43+
countryCode: info.countryCode,
44+
regionCode: info.regionCode,
45+
cityName: info.cityName,
46+
latitude: info.latitude,
47+
longitude: info.longitude,
48+
tzIdentifier: info.tzIdentifier,
49+
};
50+
}
51+
1652
type EventType = {
1753
id: string,
1854
dataSchema: yup.Schema<any>,
@@ -65,6 +101,20 @@ const SessionActivityEventType = {
65101
inherits: [UserActivityEventType],
66102
} as const satisfies SystemEventTypeBase;
67103

104+
const TokenRefreshEventType = {
105+
id: "$token-refresh",
106+
dataSchema: yupObject({
107+
projectId: yupString().defined(),
108+
branchId: yupString().defined(),
109+
organizationId: yupString().nullable().test("must-be-null", "Organization ID has not been implemented yet and must be null", (value) => value === null).defined(),
110+
userId: yupString().uuid().defined(),
111+
refreshTokenId: yupString().defined(),
112+
isAnonymous: yupBoolean().defined(),
113+
ipInfo: endUserIpInfoSchema.nullable().defined(),
114+
}),
115+
inherits: [],
116+
} as const satisfies SystemEventTypeBase;
117+
68118

69119
const ApiRequestEventType = {
70120
id: "$api-request",
@@ -84,6 +134,7 @@ export const SystemEventTypes = stripEventTypeSuffixFromKeys({
84134
ProjectActivityEventType,
85135
UserActivityEventType,
86136
SessionActivityEventType,
137+
TokenRefreshEventType,
87138
ApiRequestEventType,
88139
LegacyApiEventType,
89140
} as const);
@@ -165,9 +216,6 @@ export async function logEvent<T extends EventType[]>(
165216
const projectId = typeof dataRecord === "object" && dataRecord && typeof dataRecord.projectId === "string" ? dataRecord.projectId : "";
166217
const branchId = typeof dataRecord === "object" && dataRecord && typeof dataRecord.branchId === "string" ? dataRecord.branchId : DEFAULT_BRANCH_ID;
167218
const userId = typeof dataRecord === "object" && dataRecord && typeof dataRecord.userId === "string" ? dataRecord.userId : "";
168-
const teamId = typeof dataRecord === "object" && dataRecord && typeof dataRecord.teamId === "string" ? dataRecord.teamId : "";
169-
const sessionId = typeof dataRecord === "object" && dataRecord && typeof dataRecord.sessionId === "string" ? dataRecord.sessionId : "";
170-
const isAnonymous = typeof dataRecord === "object" && dataRecord && typeof dataRecord.isAnonymous === "boolean" ? dataRecord.isAnonymous : false;
171219

172220

173221
// rest is no more dynamic APIs so we can run it asynchronously
@@ -195,21 +243,20 @@ export async function logEvent<T extends EventType[]>(
195243
},
196244
});
197245

198-
if (isClickhouseConfigured()) {
246+
// Only log TokenRefresh events to ClickHouse
247+
if (isClickhouseConfigured() && eventTypesArray.some(e => e.id === '$token-refresh')) {
199248
const clickhouseClient = getClickhouseAdminClient();
200249
await clickhouseClient.insert({
201250
table: "analytics_internal.events",
202-
values: eventTypesArray.map(eventType => ({
203-
event_type: eventType.id,
251+
values: [{
252+
event_type: '$token-refresh',
204253
event_at: timeRange.end,
205254
data: clickhouseEventData,
206255
project_id: projectId,
207256
branch_id: branchId,
208-
user_id: userId,
209-
team_id: teamId,
210-
is_anonymous: isAnonymous,
211-
session_id: sessionId,
212-
})),
257+
user_id: userId || null,
258+
team_id: null, // Token refresh events don't have team context
259+
}],
213260
format: "JSONEachRow",
214261
clickhouse_settings: {
215262
date_time_input_format: "best_effort",

apps/backend/src/lib/tokens.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import { Result } from '@stackframe/stack-shared/dist/utils/results';
1111
import { traceSpan } from '@stackframe/stack-shared/dist/utils/telemetry';
1212
import * as jose from 'jose';
1313
import { JOSEError, JWTExpired } from 'jose/errors';
14-
import { getEndUserInfo } from './end-users';
15-
import { SystemEventTypes, logEvent } from './events';
14+
import { SystemEventTypes, getEndUserIpInfoForEvent, logEvent } from './events';
1615
import { Tenancy } from './tenancies';
1716

1817
export const authorizationHeaderSchema = yupString().matches(/^StackSession [^ ]+$/);
@@ -213,9 +212,8 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: {
213212
const now = new Date();
214213
const prisma = await getPrismaClientForTenancy(options.tenancy);
215214

216-
// Get end user IP info for session tracking
217-
const endUserInfo = await getEndUserInfo();
218-
const ipInfo = endUserInfo ? (endUserInfo.maybeSpoofed ? endUserInfo.spoofedInfo : endUserInfo.exactInfo) : undefined;
215+
// Get end user IP info for session tracking and event logging
216+
const ipInfo = await getEndUserIpInfoForEvent();
219217

220218
await Promise.all([
221219
prisma.projectUser.update({
@@ -238,7 +236,7 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: {
238236
},
239237
data: {
240238
lastActiveAt: now,
241-
lastActiveAtIpInfo: ipInfo,
239+
lastActiveAtIpInfo: ipInfo ?? undefined,
242240
},
243241
}),
244242
]);
@@ -256,6 +254,20 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: {
256254
}
257255
);
258256

257+
// Log token refresh event for ClickHouse analytics
258+
await logEvent(
259+
[SystemEventTypes.TokenRefresh],
260+
{
261+
projectId: options.tenancy.project.id,
262+
branchId: options.tenancy.branchId,
263+
userId: options.refreshTokenObj.projectUserId,
264+
refreshTokenId: options.refreshTokenObj.id,
265+
organizationId: null,
266+
isAnonymous: user.is_anonymous,
267+
ipInfo,
268+
}
269+
);
270+
259271
const payload: Omit<AccessTokenPayload, "iss" | "aud" | "iat"> = {
260272
sub: options.refreshTokenObj.projectUserId,
261273
project_id: options.tenancy.project.id,

apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const queryEvents = async (params: {
1313
SELECT event_type, project_id, branch_id, user_id, team_id
1414
FROM events
1515
WHERE 1
16-
${params.userId ? "AND user_id = {user_id:String}" : ""}
16+
${params.userId ? "AND user_id = {user_id:Nullable(String)}" : ""}
1717
${params.eventType ? "AND event_type = {event_type:String}" : ""}
1818
ORDER BY event_at DESC
1919
LIMIT 10
@@ -55,17 +55,18 @@ it("stores backend events in ClickHouse", async ({ expect }) => {
5555

5656
const queryResponse = await fetchEventsWithRetry({
5757
userId,
58-
eventType: "$session-activity",
58+
eventType: "$token-refresh",
5959
});
6060

6161
expect(queryResponse.status).toBe(200);
6262
const results = Array.isArray(queryResponse.body?.result) ? queryResponse.body.result : [];
6363
expect(results.length).toBeGreaterThan(0);
6464
expect(results[0]).toMatchObject({
65-
event_type: "$session-activity",
65+
event_type: "$token-refresh",
6666
project_id: projectId,
6767
branch_id: "main",
6868
user_id: userId,
69+
team_id: null,
6970
});
7071
});
7172

@@ -79,7 +80,7 @@ it("cannot read events from other projects", async ({ expect }) => {
7980
const { userId: projectBUserId } = await Auth.Otp.signIn();
8081
const projectBResponse = await fetchEventsWithRetry({
8182
userId: projectBUserId,
82-
eventType: "$session-activity",
83+
eventType: "$token-refresh",
8384
});
8485
expect(projectBResponse).toMatchInlineSnapshot(`
8586
NiceResponse {
@@ -88,9 +89,9 @@ it("cannot read events from other projects", async ({ expect }) => {
8889
"result": [
8990
{
9091
"branch_id": "main",
91-
"event_type": "$session-activity",
92+
"event_type": "$token-refresh",
9293
"project_id": "<stripped UUID>",
93-
"team_id": "",
94+
"team_id": null,
9495
"user_id": "<stripped UUID>",
9596
},
9697
],
@@ -109,7 +110,7 @@ it("cannot read events from other projects", async ({ expect }) => {
109110

110111
const queryResponse = await queryEvents({
111112
userId: projectBUserId,
112-
eventType: "$session-activity",
113+
eventType: "$token-refresh",
113114
});
114115
expect(queryResponse).toMatchInlineSnapshot(`
115116
NiceResponse {
@@ -134,7 +135,7 @@ it("filters analytics events by user within a project", async ({ expect }) => {
134135

135136
const userAResponse = await fetchEventsWithRetry({
136137
userId: userA,
137-
eventType: "$session-activity",
138+
eventType: "$token-refresh",
138139
});
139140
expect(userAResponse.status).toBe(200);
140141
const userAResults = Array.isArray(userAResponse.body?.result) ? userAResponse.body.result : [];
@@ -143,7 +144,7 @@ it("filters analytics events by user within a project", async ({ expect }) => {
143144

144145
const userBResponse = await fetchEventsWithRetry({
145146
userId: userB,
146-
eventType: "$session-activity",
147+
eventType: "$token-refresh",
147148
});
148149
expect(userBResponse.status).toBe(200);
149150
const userBResults = Array.isArray(userBResponse.body?.result) ? userBResponse.body.result : [];

0 commit comments

Comments
 (0)