diff --git a/apps/backend/src/app/api/latest/internal/projects-metrics/route.tsx b/apps/backend/src/app/api/latest/internal/projects-metrics/route.tsx index 30823d3b02..2646cbc1f7 100644 --- a/apps/backend/src/app/api/latest/internal/projects-metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/projects-metrics/route.tsx @@ -164,9 +164,9 @@ export const GET = createSmartRouteHandler({ }), ]); [totalRows, signupRows] = await Promise.all([ - totalResult.json(), - signupResult.json(), - ]) as any; + totalResult.json<{ projectId: string, totalUsers: string | number }>(), + signupResult.json<{ projectId: string, day: string, signups: string | number }>(), + ]); } catch (cause) { throw new StackAssertionError("Failed to load project metrics.", { cause, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index 25108d862b..0989df2167 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -680,12 +680,16 @@ it("rejects batch when remaining quota is less than batch size and does not debi // Drain async logEvent debits before forcing the quota down to a known // value — otherwise a trailing in-flight debit would push it negative // after we set it to 2 and break the post-condition. - // `minimumElapsedMs` guards against returning before the async events - // have started firing. + // + // `Auth.Otp.signIn()` triggers async events via `runAsynchronouslyAndWaitUntil` + // (e.g. $token-refresh, $sign-up-rule-trigger) that debit analytics quota. + // Under CI load with 8 parallel workers, these async callbacks can be delayed + // 5+ seconds after the HTTP response. `minimumElapsedMs: 10_000` ensures we + // don't declare stability before the async pipeline has had time to fire. await waitForItemQuantityToStabilize( ownerTeamId, ITEM_IDS.analyticsEvents, - { minimumElapsedMs: 5000 }, + { minimumElapsedMs: 10_000 }, ); await setItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents, 2); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts index 3dd1311678..a7dd30283b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts @@ -60,28 +60,26 @@ const queryEventDataJson = async (params: { }, }); -// Defaults give 40 attempts * 500ms = ~20s of polling. -// // The events under test are produced *asynchronously* by the sign-in path: // `runAsynchronouslyAndWaitUntil(logEvent)` fires after the HTTP response // returns and runs through SDK self-call → quota debit → Postgres insert → // ClickHouse async_insert (which is server-buffered, no wait_for_async_insert). // Under CI load this whole pipeline can take well over 10s before the row -// becomes queryable, so the previous 7.5s window was still flaking with -// "expected 0 to be greater than 0". 20s is conservative; the loop breaks -// out as soon as the row appears, so there's no cost on the happy path. -const DEFAULT_QUERY_RETRY_ATTEMPTS = 40; +// becomes queryable. We use a 30s time-based timeout (via performance.now()) +// which is conservative; the loop breaks out as soon as the row appears. +const DEFAULT_QUERY_TIMEOUT_MS = 30_000; const DEFAULT_QUERY_RETRY_DELAY_MS = 500; const fetchEventDataJsonWithRetry = async ( params: { userId?: string, eventType?: string }, - options: { attempts?: number, delayMs?: number } = {} + options: { timeoutMs?: number, delayMs?: number } = {} ) => { - const attempts = options.attempts ?? DEFAULT_QUERY_RETRY_ATTEMPTS; + const timeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS; const delayMs = options.delayMs ?? DEFAULT_QUERY_RETRY_DELAY_MS; + const startedAt = performance.now(); let response = await queryEventDataJson(params); - for (let attempt = 0; attempt < attempts; attempt++) { + while (performance.now() - startedAt < timeoutMs) { if (response.status !== 200) { break; } @@ -98,13 +96,14 @@ const fetchEventDataJsonWithRetry = async ( const fetchEventsWithRetry = async ( params: { userId?: string, eventType?: string }, - options: { attempts?: number, delayMs?: number } = {} + options: { timeoutMs?: number, delayMs?: number } = {} ) => { - const attempts = options.attempts ?? DEFAULT_QUERY_RETRY_ATTEMPTS; + const timeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS; const delayMs = options.delayMs ?? DEFAULT_QUERY_RETRY_DELAY_MS; + const startedAt = performance.now(); let response = await queryEvents(params); - for (let attempt = 0; attempt < attempts; attempt++) { + while (performance.now() - startedAt < timeoutMs) { if (response.status !== 200) { break; } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/refresh-race.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/refresh-race.test.ts index 490ce4e354..b331c0bda2 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/refresh-race.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/refresh-race.test.ts @@ -37,7 +37,11 @@ function collectUnexpectedRaceResponseFailures(options: { it("does not 500 when a refresh races with a sign-out of the same session", { timeout: 120_000 }, async ({ expect }) => { // Fire many refresh+signout pairs concurrently to hit the race window // between findFirst(refreshToken) and projectUserRefreshToken.update(). - const ATTEMPTS = 10; + // + // 5 attempts is sufficient to trigger the race condition reliably; 10 was + // causing timeouts under CI load where each signUp takes 5–15s (CI runs + // showed 193–233s for 10 iterations with a 120s timeout). + const ATTEMPTS = 5; const failures: RaceFailure[] = []; for (let i = 0; i < ATTEMPTS; i++) { @@ -80,7 +84,7 @@ it("does not 500 when a refresh races with a sign-out of the same session", { ti it("does not 500 when an OAuth refresh-token grant races with a sign-out of the same session", { timeout: 120_000 }, async ({ expect }) => { // The OAuth token endpoint uses the same refresh-token helper as the direct // session refresh endpoint, so keep this regression covered on both callers. - const ATTEMPTS = 10; + const ATTEMPTS = 5; const failures: RaceFailure[] = []; for (let i = 0; i < ATTEMPTS; i++) { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/index.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/index.test.ts index 479269e127..01ccc48408 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/index.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/index.test.ts @@ -67,7 +67,7 @@ it("creates sessions that expire", async ({ expect }) => { method: "POST", body: { user_id: res.userId, - expires_in_millis: 5_000, + expires_in_millis: 10_000, }, }); expect(res2).toMatchInlineSnapshot(` @@ -80,7 +80,7 @@ it("creates sessions that expire", async ({ expect }) => { "headers": Headers {