Skip to content

Commit 72cdbb7

Browse files
authored
Merge branch 'dev' into promptless/document-apple-bundle-ids
2 parents 4eec797 + 4c22b37 commit 72cdbb7

14 files changed

Lines changed: 262 additions & 141 deletions

File tree

.github/workflows/db-migration-backwards-compatibility.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ jobs:
215215
run: pnpm test
216216

217217
- name: Verify data integrity
218-
run: pnpm run verify-data-integrity
218+
run: pnpm run verify-data-integrity --no-bail
219219

220220
- name: Print Docker Compose logs
221221
if: always()

.github/workflows/e2e-api-tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ jobs:
156156
run: pnpm test ${{ matrix.freestyle-mode == 'prod' && '--min-workers=1 --max-workers=1' || '' }}
157157

158158
- name: Verify data integrity
159-
run: pnpm run verify-data-integrity
159+
run: pnpm run verify-data-integrity --no-bail
160160

161161
- name: Print Docker Compose logs
162162
if: always()

.github/workflows/e2e-custom-base-port-api-tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ jobs:
150150
run: pnpm test
151151

152152
- name: Verify data integrity
153-
run: pnpm run verify-data-integrity
153+
run: pnpm run verify-data-integrity --no-bail
154154

155155
- name: Print Docker Compose logs
156156
if: always()

.github/workflows/e2e-source-of-truth-api-tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ jobs:
156156
run: pnpm test
157157

158158
- name: Verify data integrity
159-
run: pnpm run verify-data-integrity
159+
run: pnpm run verify-data-integrity --no-bail
160160

161161
- name: Print Docker Compose logs
162162
if: always()

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/scripts/verify-data-integrity/index.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ const USE_MOCK_STRIPE_API = STRIPE_SECRET_KEY === "sk_test_mockstripekey";
2121
let targetOutputData: OutputData | undefined = undefined;
2222
const currentOutputData: OutputData = {};
2323

24-
const recurse = createRecurse();
25-
2624
async function main() {
2725
console.log();
2826
console.log();
@@ -80,6 +78,13 @@ async function main() {
8078
const shouldVerifyOutput = flags.includes("--verify-output");
8179
const shouldSkipNeon = flags.includes("--skip-neon");
8280
const recentFirst = flags.includes("--recent-first");
81+
const noBail = flags.includes("--no-bail");
82+
83+
const { recurse, collectedErrors } = createRecurse({ noBail });
84+
85+
if (noBail) {
86+
console.log(`Running in no-bail mode: will continue on errors and report all at the end.`);
87+
}
8388

8489
if (shouldSaveOutput) {
8590
console.log(`Will save output to ${OUTPUT_FILE_PATH}`);
@@ -318,6 +323,27 @@ async function main() {
318323
console.log(`Output saved to ${OUTPUT_FILE_PATH}`);
319324
}
320325

326+
// Report collected errors if in no-bail mode
327+
if (collectedErrors.length > 0) {
328+
console.log();
329+
console.log();
330+
console.log();
331+
console.log();
332+
console.log("===================================================");
333+
console.log(`\x1b[41mFAILED\x1b[0m! Found ${collectedErrors.length} error(s):`);
334+
console.log();
335+
for (let i = 0; i < collectedErrors.length; i++) {
336+
const { context, error } = collectedErrors[i];
337+
console.log(`--- Error ${i + 1}/${collectedErrors.length} ---`);
338+
console.log(`Context: ${context}`);
339+
console.error(error);
340+
console.log();
341+
}
342+
console.log("===================================================");
343+
console.log();
344+
process.exit(1);
345+
}
346+
321347
console.log();
322348
console.log();
323349
console.log();
Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
export type RecurseFunction = (progressPrefix: string, inner: (recurse: RecurseFunction) => Promise<void>) => Promise<void>;
22

3-
export function createRecurse(): RecurseFunction {
3+
export type CollectedError = {
4+
context: string,
5+
error: unknown,
6+
};
7+
8+
export function createRecurse(options: { noBail: boolean }): { recurse: RecurseFunction, collectedErrors: CollectedError[] } {
49
let lastProgress = performance.now() - 9999999999;
10+
const collectedErrors: CollectedError[] = [];
511

612
const _recurse = async (
713
progressPrefix: string | ((...args: any[]) => void),
814
inner: Parameters<RecurseFunction>[1],
15+
contextPath: string = "",
916
): Promise<void> => {
1017
const progressFunc = typeof progressPrefix === "function" ? progressPrefix : (...args: any[]) => {
1118
console.log(`${progressPrefix}`, ...args);
1219
};
20+
const currentContext = typeof progressPrefix === "string" ? progressPrefix : contextPath;
1321
if (performance.now() - lastProgress > 1000) {
1422
progressFunc();
1523
lastProgress = performance.now();
@@ -19,14 +27,24 @@ export function createRecurse(): RecurseFunction {
1927
(progressPrefix, inner) => _recurse(
2028
(...args) => progressFunc(progressPrefix, ...args),
2129
inner,
30+
`${currentContext} > ${typeof progressPrefix === "string" ? progressPrefix : ""}`,
2231
),
2332
);
2433
} catch (error) {
2534
progressFunc(`\x1b[41mERROR\x1b[0m!`);
26-
throw error;
35+
if (options.noBail) {
36+
collectedErrors.push({
37+
context: currentContext,
38+
error,
39+
});
40+
} else {
41+
throw error;
42+
}
2743
}
2844
};
2945

30-
return _recurse;
46+
const recurse: RecurseFunction = (progressPrefix, inner) => _recurse(progressPrefix, inner, progressPrefix);
47+
48+
return { recurse, collectedErrors };
3149
}
3250

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

Lines changed: 92 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { Prisma } from "@/generated/prisma/client";
22
import { getClickhouseAdminClient, isClickhouseConfigured } from "@/lib/clickhouse";
3+
import { endUserIpInfoSchema, type EndUserIpInfo } from "@/lib/events";
34
import { DEFAULT_BRANCH_ID } from "@/lib/tenancies";
45
import { globalPrismaClient } from "@/prisma-client";
56
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
67
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
7-
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
8+
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
89

910
type Cursor = {
1011
created_at_millis: number,
@@ -22,41 +23,86 @@ const parseMillisOrThrow = (value: number | undefined, field: string) => {
2223
return parsed;
2324
};
2425

25-
const createClickhouseRows = (event: {
26+
type EventWithIpInfo = {
2627
id: string,
2728
systemEventTypeIds: string[],
28-
data: any,
29+
data: unknown,
2930
eventEndedAt: Date,
3031
eventStartedAt: Date,
3132
isWide: boolean,
32-
}) => {
33+
isEndUserIpInfoGuessTrusted: boolean,
34+
endUserIpInfoGuess: {
35+
ip: string,
36+
countryCode: string | null,
37+
regionCode: string | null,
38+
cityName: string | null,
39+
latitude: number | null,
40+
longitude: number | null,
41+
tzIdentifier: string | null,
42+
} | null,
43+
};
44+
45+
/**
46+
* Transforms a $session-activity event from Postgres into a $token-refresh event for ClickHouse.
47+
*
48+
* The $session-activity event has:
49+
* - sessionId (from SessionActivityEventType)
50+
* - userId, branchId, isAnonymous, teamId (from UserActivityEventType)
51+
* - projectId (from ProjectEventType via inheritance)
52+
*
53+
* The $token-refresh event needs:
54+
* - projectId, branchId, organizationId (null), userId, refreshTokenId, isAnonymous, ipInfo
55+
*/
56+
const createClickhouseRow = (event: EventWithIpInfo) => {
3357
const dataRecord = typeof event.data === "object" && event.data !== null ? event.data as Record<string, unknown> : {};
34-
const clickhouseEventData = {
35-
...dataRecord,
36-
is_wide: event.isWide,
37-
event_started_at: event.eventStartedAt,
38-
event_ended_at: event.eventEndedAt,
39-
};
40-
const projectId = typeof dataRecord.projectId === "string" ? dataRecord.projectId : "";
41-
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 : "";
58+
59+
// Extract fields from the old $session-activity format
60+
const projectId = typeof dataRecord.projectId === "string" ? dataRecord.projectId : throwErr(new StackAssertionError("projectId is required"));
61+
const branchId = typeof dataRecord.branchId === "string" ? dataRecord.branchId : DEFAULT_BRANCH_ID;
62+
const userId = typeof dataRecord.userId === "string" && dataRecord.userId ? dataRecord.userId : throwErr(new StackAssertionError("userId is required"));
63+
// sessionId becomes refreshTokenId in the new schema
64+
const refreshTokenId = typeof dataRecord.sessionId === "string" ? dataRecord.sessionId : throwErr(new StackAssertionError("sessionId is required"));
65+
// isAnonymous may not exist on old events, default to false
4566
const isAnonymous = typeof dataRecord.isAnonymous === "boolean" ? dataRecord.isAnonymous : false;
4667

47-
const eventTypes = [...new Set(event.systemEventTypeIds)];
68+
// Build ipInfo from the event's endUserIpInfoGuess relation
69+
let ipInfo: EndUserIpInfo | null = null;
70+
if (event.endUserIpInfoGuess) {
71+
const ip = event.endUserIpInfoGuess;
72+
ipInfo = {
73+
ip: ip.ip,
74+
isTrusted: event.isEndUserIpInfoGuessTrusted,
75+
countryCode: ip.countryCode ?? undefined,
76+
regionCode: ip.regionCode ?? undefined,
77+
cityName: ip.cityName ?? undefined,
78+
latitude: ip.latitude ?? undefined,
79+
longitude: ip.longitude ?? undefined,
80+
tzIdentifier: ip.tzIdentifier ?? undefined,
81+
};
82+
// Validate against schema
83+
ipInfo = endUserIpInfoSchema.nullable().defined().validateSync(ipInfo, { stripUnknown: true });
84+
}
85+
86+
// Build the data object matching TokenRefreshEventType schema
87+
const tokenRefreshData = {
88+
projectId,
89+
branchId,
90+
organizationId: null,
91+
userId: userId,
92+
refreshTokenId,
93+
isAnonymous,
94+
ipInfo,
95+
};
4896

49-
return eventTypes.map(eventType => ({
50-
event_type: eventType,
97+
return {
98+
event_type: '$token-refresh',
5199
event_at: event.eventEndedAt,
52-
data: clickhouseEventData,
100+
data: tokenRefreshData,
53101
project_id: projectId,
54102
branch_id: branchId,
55103
user_id: userId,
56-
team_id: teamId,
57-
session_id: sessionId,
58-
is_anonymous: isAnonymous,
59-
}));
104+
team_id: null,
105+
};
60106
};
61107

62108
export const POST = createSmartRouteHandler({
@@ -106,11 +152,17 @@ export const POST = createSmartRouteHandler({
106152
const cursorId = body.cursor?.id;
107153
const limit = body.limit;
108154

155+
const laterOfMinCreatedAtOrCursorCreatedAt = !cursorCreatedAt || minCreatedAt > cursorCreatedAt ? minCreatedAt : cursorCreatedAt;
156+
109157
const baseWhere: Prisma.EventWhereInput = {
110158
createdAt: {
111-
gte: minCreatedAt,
159+
gte: laterOfMinCreatedAtOrCursorCreatedAt,
112160
lt: maxCreatedAt,
113161
},
162+
// Only migrate $session-activity events (translated to $token-refresh in ClickHouse)
163+
systemEventTypeIds: {
164+
has: '$session-activity',
165+
},
114166
};
115167

116168
const cursorFilter: Prisma.EventWhereInput | undefined = (cursorCreatedAt && cursorId) ? {
@@ -131,6 +183,9 @@ export const POST = createSmartRouteHandler({
131183
{ id: "asc" },
132184
],
133185
take: limit,
186+
include: {
187+
endUserIpInfoGuess: true,
188+
},
134189
});
135190

136191
let insertedRows = 0;
@@ -141,22 +196,19 @@ export const POST = createSmartRouteHandler({
141196
throw new StatusError(StatusError.ServiceUnavailable, "ClickHouse is not configured");
142197
}
143198
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);
147-
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-
}
199+
const rowsToInsert = events.map(createClickhouseRow);
200+
migratedEvents = events.length;
201+
202+
await clickhouseClient.insert({
203+
table: "analytics_internal.events",
204+
values: rowsToInsert,
205+
format: "JSONEachRow",
206+
clickhouse_settings: {
207+
date_time_input_format: "best_effort",
208+
async_insert: 1,
209+
},
210+
});
211+
insertedRows = rowsToInsert.length;
160212
}
161213

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

0 commit comments

Comments
 (0)