From 5103bace4f2d2b040118ae0cb982d0f0a8d81ba4 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 4 Aug 2026 19:26:05 +0200 Subject: [PATCH] perf(web): stop blocking every API request on a Clerk token fetch Production telemetry showed ~580ms p50 / 3.2s p90 elapsing between the browser issuing a request and the API Worker's server span starting, against a 204ms median handler (n=1485 maple-web -> maple-api traces). The gap was uniform across every endpoint in the app, which is the signature of a fixed per-request cost rather than per-query work. It was `mapleFetch` awaiting an uncached Clerk `getToken()` before every outbound fetch. Clerk session JWTs live 60s, so most page loads paid a full cross-origin round-trip to Clerk before the first byte went to the API. - auth-headers now caches the resolved bearer until it is nearly spent and refreshes ahead of expiry, so no request blocks on the identity provider. The refresh is single-flighted, and a generation counter discards one that resolves after the identity changed. - The cache is dropped on org switch: the JWT encodes the active org, so a token outliving a switch would query the previous org's data. - Only JWT-shaped bearers are cached. An opaque token (self-hosted, read synchronously from sessionStorage) has no expiry we can trust. - A 401 now invalidates the cache and retries once, so a token that goes stale ahead of its own `exp` self-heals instead of failing the page. Also, on the session replays list specifically: - Add a route loader mounting both queries. The router runs defaultPreload: "intent", so they now start on hover rather than after hydration and the route chunk evaluate. The filter-input builder is extracted and tested so the loader and the component provably key to the same atom entry -- a mismatch would silently double the requests. - Narrow the org ClickHouse settings hot-path read from SELECT * (14 columns including encrypted password blobs) to the 8 that are actually cached. This query runs on every warehouse execution and was measured at p50 2.1s in the API Worker. - Drop a comment claiming EdgeCacheService single-flights in-flight computes; it deliberately does not, because Cloudflare ties I/O objects to the request that created them. isWarehouseWriteReady deliberately keeps its uncached read. Routing it through the 5-minute memo made a freshly-connected BYO-CH org report not-write-ready for minutes after onboarding, sending reads to Tinybird while the gateway already writes to ClickHouse. --- .../org/OrgClickHouseSettingsService.ts | 146 +++++++++++++----- .../replays/replays-filter-inputs.test.ts | 97 ++++++++++++ .../replays/replays-filter-inputs.ts | 58 +++++++ .../web/src/hooks/use-effective-time-range.ts | 32 +++- apps/web/src/hooks/use-infinite-replays.ts | 4 +- .../src/lib/services/common/atom-client.ts | 23 ++- .../lib/services/common/auth-headers.test.ts | 105 ++++++++++++- .../src/lib/services/common/auth-headers.ts | 108 ++++++++++++- apps/web/src/routes/replays/index.tsx | 48 +++--- 9 files changed, 539 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/components/replays/replays-filter-inputs.test.ts create mode 100644 apps/web/src/components/replays/replays-filter-inputs.ts diff --git a/apps/api/src/services/org/OrgClickHouseSettingsService.ts b/apps/api/src/services/org/OrgClickHouseSettingsService.ts index cc7787d86..cfb24747a 100644 --- a/apps/api/src/services/org/OrgClickHouseSettingsService.ts +++ b/apps/api/src/services/org/OrgClickHouseSettingsService.ts @@ -62,6 +62,19 @@ type RuntimeBackendConfig = { type ActiveRow = typeof orgClickHouseSettings.$inferSelect +/** The columns `resolveRuntimeConfig` actually caches — see `selectCachedRow`. */ +type CachedSettingsRow = Pick< + ActiveRow, + | "schemaVersion" + | "syncStatus" + | "chUrl" + | "chUser" + | "chDatabase" + | "chPasswordCiphertext" + | "chPasswordIv" + | "chPasswordTag" +> + // Edge-cache bucket + TTL for the per-org runtime ClickHouse config lookup. // `resolveRuntimeConfig` runs on the hot path of every warehouse SQL execution // (and once per missing bucket in the cache fan-out), so a long-lived @@ -104,7 +117,7 @@ const CachedChSettings = Schema.Struct({ type CachedChSettings = typeof CachedChSettings.Type const CachedChSettingsOrNull = Schema.NullOr(CachedChSettings) -const toCachedChSettings = (row: ActiveRow): CachedChSettings => ({ +const toCachedChSettings = (row: CachedSettingsRow): CachedChSettings => ({ schemaVersion: row.schemaVersion, chUrl: row.chUrl, chUser: row.chUser, @@ -772,6 +785,34 @@ export class OrgClickHouseSettingsService extends Context.Service< return Option.fromNullishOr(rows[0]) }) + // The hot-path read: only the columns that end up in `CachedChSettings`. + // `selectActiveRow`'s `SELECT *` pulls all 14 including the encrypted + // password blobs and the migration bookkeeping, none of which this path + // looks at — and this is the read that runs on every warehouse query. + const selectCachedRow = Effect.fn("OrgClickHouseSettingsService.selectCachedRow")(function* ( + orgId: OrgId, + ) { + const rows = yield* database + .execute((db) => + db + .select({ + schemaVersion: orgClickHouseSettings.schemaVersion, + syncStatus: orgClickHouseSettings.syncStatus, + chUrl: orgClickHouseSettings.chUrl, + chUser: orgClickHouseSettings.chUser, + chDatabase: orgClickHouseSettings.chDatabase, + chPasswordCiphertext: orgClickHouseSettings.chPasswordCiphertext, + chPasswordIv: orgClickHouseSettings.chPasswordIv, + chPasswordTag: orgClickHouseSettings.chPasswordTag, + }) + .from(orgClickHouseSettings) + .where(eq(orgClickHouseSettings.orgId, orgId)) + .limit(1), + ) + .pipe(Effect.mapError(toPersistenceError)) + return Option.fromNullishOr(rows[0]) + }) + // Bust the cached runtime config for an org after any write to its settings // row, so the next warehouse query re-resolves rather than serving a stale // value. Clears both the in-isolate memo (this isolate only — other isolates @@ -1166,52 +1207,65 @@ export class OrgClickHouseSettingsService extends Context.Service< }) }) - const resolveRuntimeConfig = Effect.fn("OrgClickHouseSettingsService.resolveRuntimeConfig")( + // The cached settings row behind both `resolveRuntimeConfig` and + // `isWarehouseWriteReady`. + // + // `selectCachedRow` is a Postgres round-trip on the hot path of EVERY + // warehouse SQL execution, and the bucket-cache fan-out re-runs it once per + // missing range. Two cache layers sit in front: a module-scoped in-isolate + // memo (zero network on a warm isolate) and, on a memo miss, the shared edge + // cache (a 1h entry, removing the cold round-trip on repeat loads). + // + // Note there is NO single-flight: `EdgeCacheService.getOrCompute` deliberately + // refuses to share an in-flight Effect across requests, because Cloudflare ties + // I/O objects to the request that created them. Concurrent cold misses each pay + // their own lookup and converge through the cache afterwards. + // + // Both layers store the ENCRYPTED row projection (or `null`) and decryption + // happens per-request in `resolveRuntimeConfig`, so plaintext credentials never + // enter a cache. + const resolveCachedSettings = Effect.fn("OrgClickHouseSettingsService.resolveCachedSettings")( function* (orgId: OrgId) { - // `selectActiveRow` is a Postgres round-trip on the hot path of EVERY - // warehouse SQL execution, and the bucket-cache fan-out re-runs it once - // per missing range. Two cache layers sit in front: a module-scoped - // in-isolate memo (zero network on a warm isolate) and, on a memo miss, - // the shared edge cache (its in-flight single-flight collapses the - // concurrent fan-out into one lookup; the 5-min entry removes the cold - // round-trip on repeat loads). Both store the ENCRYPTED row projection - // (or `null`) and decrypt per-request below, so plaintext credentials - // never enter a cache. const nowMs = yield* Clock.currentTimeMillis const memoized = runtimeConfigMemo.get(orgId) - let cached: CachedChSettings | null if (memoized !== undefined && memoized.expiresAt > nowMs) { yield* Effect.annotateCurrentSpan("clickhouse.config.memoHit", true) - cached = memoized.value - } else { - yield* Effect.annotateCurrentSpan("clickhouse.config.memoHit", false) - const edgeCache = yield* Effect.serviceOption(EdgeCacheService) - const lookup = selectActiveRow(orgId).pipe( - Effect.map((row) => (Option.isSome(row) ? toCachedChSettings(row.value) : null)), - ) - cached = Option.isNone(edgeCache) - ? yield* lookup - : yield* edgeCache.value - .getOrCompute( - { - bucket: ORG_CH_CONFIG_BUCKET, - key: orgId, - ttlSeconds: ORG_CH_CONFIG_TTL_SECONDS, - schema: CachedChSettingsOrNull, - }, - lookup, - ) - .pipe( - Effect.tap((result) => - Effect.annotateCurrentSpan("clickhouse.config.cacheHit", result.hit), - ), - Effect.map((result) => result.value), - ) - runtimeConfigMemo.set(orgId, { - value: cached, - expiresAt: nowMs + ORG_CH_CONFIG_MEMO_TTL_MS, - }) + return memoized.value } + yield* Effect.annotateCurrentSpan("clickhouse.config.memoHit", false) + const edgeCache = yield* Effect.serviceOption(EdgeCacheService) + const lookup = selectCachedRow(orgId).pipe( + Effect.map((row) => (Option.isSome(row) ? toCachedChSettings(row.value) : null)), + ) + const cached: CachedChSettings | null = Option.isNone(edgeCache) + ? yield* lookup + : yield* edgeCache.value + .getOrCompute( + { + bucket: ORG_CH_CONFIG_BUCKET, + key: orgId, + ttlSeconds: ORG_CH_CONFIG_TTL_SECONDS, + schema: CachedChSettingsOrNull, + }, + lookup, + ) + .pipe( + Effect.tap((result) => + Effect.annotateCurrentSpan("clickhouse.config.cacheHit", result.hit), + ), + Effect.map((result) => result.value), + ) + runtimeConfigMemo.set(orgId, { + value: cached, + expiresAt: nowMs + ORG_CH_CONFIG_MEMO_TTL_MS, + }) + return cached + }, + ) + + const resolveRuntimeConfig = Effect.fn("OrgClickHouseSettingsService.resolveRuntimeConfig")( + function* (orgId: OrgId) { + const cached = yield* resolveCachedSettings(orgId) if (cached === null) { return Option.none() @@ -1250,9 +1304,17 @@ export class OrgClickHouseSettingsService extends Context.Service< // Unlike `resolveRuntimeConfig` — which deliberately ignores readiness because // the org's own collector writes traces/logs straight to its CH regardless — // this gate matters for data whose ONLY writer is the readiness-aware gateway. + // + // Deliberately NOT behind `resolveCachedSettings`, even though it is the same + // row. That path memoizes per isolate for 5 minutes, and this gate decides + // which warehouse a read is answered from: a stale `false` right after + // onboarding flips sends reads to Tinybird while the gateway is already + // writing to the org's ClickHouse, so the data silently goes missing until the + // memo expires. It reads the narrow projection rather than `SELECT *`, but it + // reads it fresh. const isWarehouseWriteReady = Effect.fn("OrgClickHouseSettingsService.isWarehouseWriteReady")( function* (orgId: OrgId) { - const row = yield* selectActiveRow(orgId) + const row = yield* selectCachedRow(orgId) return ( Option.isSome(row) && row.value.syncStatus === "connected" && diff --git a/apps/web/src/components/replays/replays-filter-inputs.test.ts b/apps/web/src/components/replays/replays-filter-inputs.test.ts new file mode 100644 index 000000000..bc28e8245 --- /dev/null +++ b/apps/web/src/components/replays/replays-filter-inputs.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { encodeKey } from "@/lib/cache-key" +import { REPLAYS_PAGE_SIZE } from "@/hooks/use-infinite-replays" +import { replaysFilterInputs, type ReplaysSearchState } from "./replays-filter-inputs" + +/** + * The route's `loader` prefetches both list queries and the component then reads + * them. That only pays off if the two produce the *same* atom-family key — a + * mismatch silently doubles the requests instead of halving the latency, and + * nothing else in the app would notice. These lock the invariant down. + */ +describe("replaysFilterInputs", () => { + const listKey = (search: ReplaysSearchState) => + encodeKey({ ...replaysFilterInputs(search), limit: REPLAYS_PAGE_SIZE, offset: 0 }) + const facetsKey = (search: ReplaysSearchState) => encodeKey(replaysFilterInputs(search)) + + afterEach(() => { + vi.useRealTimers() + }) + + it("keys identically across the loader/component gap for a relative preset", () => { + // The loader resolves `now`, then the component resolves it again after the + // route chunk evaluates. `encodeKey` snaps to a 15s grid, so both land on one + // entry — this is what makes the prefetch a prefetch rather than a duplicate. + // Pinned to the start of a bucket and advanced 14s, which is far more than the + // real gap and keeps the assertion off a grid boundary. + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-08-04T12:00:00.000Z")) + const search: ReplaysSearchState = { timePreset: "24h" } + + const loaderList = listKey(search) + const loaderFacets = facetsKey(search) + vi.advanceTimersByTime(14_000) + + expect(listKey(search)).toBe(loaderList) + expect(facetsKey(search)).toBe(loaderFacets) + }) + + it("keys identically with no search params at all (the default 24h entry)", () => { + expect(listKey({})).toBe(listKey({ timePreset: "24h" })) + }) + + it("keys identically for an absolute window", () => { + const search: ReplaysSearchState = { + startTime: "2026-08-01 00:00:00", + endTime: "2026-08-02 00:00:00", + } + + expect(listKey(search)).toBe(listKey(search)) + expect(replaysFilterInputs(search)).toMatchObject({ + startTime: "2026-08-01 00:00:00", + endTime: "2026-08-02 00:00:00", + }) + }) + + it("separates the list and facets keys, which query different shapes", () => { + const search: ReplaysSearchState = { timePreset: "24h" } + + expect(listKey(search)).not.toBe(facetsKey(search)) + }) + + it("changes the key when a filter changes", () => { + const base: ReplaysSearchState = { startTime: "2026-08-01 00:00:00", endTime: "2026-08-02 00:00:00" } + + expect(listKey({ ...base, browser: "Chrome" })).not.toBe(listKey(base)) + expect(listKey({ ...base, hasErrors: true })).not.toBe(listKey(base)) + expect(listKey({ ...base, q: "checkout" })).not.toBe(listKey(base)) + }) + + it("converts the whole-second URL params the warehouse wants in milliseconds", () => { + const inputs = replaysFilterInputs({ + startTime: "2026-08-01 00:00:00", + endTime: "2026-08-02 00:00:00", + durationMin: 30, + durationMax: 600, + activeMin: 5, + activeMax: 120, + }) + + expect(inputs).toMatchObject({ + durationMinMs: 30_000, + durationMaxMs: 600_000, + activeTimeMinMs: 5_000, + activeTimeMaxMs: 120_000, + }) + }) + + it("leaves unset bounds undefined rather than sending 0", () => { + const inputs = replaysFilterInputs({ + startTime: "2026-08-01 00:00:00", + endTime: "2026-08-02 00:00:00", + }) + + expect(inputs.durationMinMs).toBeUndefined() + expect(inputs.activeTimeMaxMs).toBeUndefined() + }) +}) diff --git a/apps/web/src/components/replays/replays-filter-inputs.ts b/apps/web/src/components/replays/replays-filter-inputs.ts new file mode 100644 index 000000000..541e2dcff --- /dev/null +++ b/apps/web/src/components/replays/replays-filter-inputs.ts @@ -0,0 +1,58 @@ +import { resolveEffectiveTimeRange } from "@/hooks/use-effective-time-range" + +/** + * The URL state the replays list filters on. Structurally the decoded search + * schema from the route, declared here so this module stays importable without + * pulling in the route (and its component tree). + */ +export interface ReplaysSearchState { + readonly startTime?: string + readonly endTime?: string + readonly timePreset?: string + readonly service?: string + readonly browser?: string + readonly country?: string + readonly deviceType?: string + readonly userId?: string + readonly visitorId?: string + readonly hasErrors?: boolean + readonly q?: string + readonly durationMin?: number + readonly durationMax?: number + readonly activeMin?: number + readonly activeMax?: number +} + +/** + * Warehouse filter inputs for a given URL state. + * + * Shared by the route's `loader` and its component so both key to the identical + * atom-family entry — the loader's prefetch is only worth anything if the + * component reads the same key. Atom family keys run through `encodeKey`, which + * snaps timestamps to a 15s grid, so resolving `now` twice a few milliseconds + * apart still lands on one entry rather than fetching twice. + */ +export const replaysFilterInputs = (search: ReplaysSearchState) => { + const { startTime, endTime } = resolveEffectiveTimeRange( + search.startTime, + search.endTime, + search.timePreset ?? "24h", + ) + return { + startTime, + endTime, + serviceName: search.service, + browser: search.browser, + country: search.country, + deviceType: search.deviceType, + userId: search.userId, + visitorId: search.visitorId, + hasErrors: search.hasErrors, + search: search.q, + // URL params are whole seconds; the warehouse filters in ms. + durationMinMs: search.durationMin != null ? search.durationMin * 1000 : undefined, + durationMaxMs: search.durationMax != null ? search.durationMax * 1000 : undefined, + activeTimeMinMs: search.activeMin != null ? search.activeMin * 1000 : undefined, + activeTimeMaxMs: search.activeMax != null ? search.activeMax * 1000 : undefined, + } +} diff --git a/apps/web/src/hooks/use-effective-time-range.ts b/apps/web/src/hooks/use-effective-time-range.ts index 4c917d20c..647af9d7a 100644 --- a/apps/web/src/hooks/use-effective-time-range.ts +++ b/apps/web/src/hooks/use-effective-time-range.ts @@ -7,6 +7,26 @@ interface TimeRange { endTime: string } +/** + * The hook's resolution, without the hook — so a router `loader` can build the + * exact same query inputs the component will and prefetch against them. Safe to + * call from both: atom family keys run through `encodeKey`, which snaps + * timestamps to a 15s grid, so a loader and a component resolving `now` + * milliseconds apart land on the same cache entry. + */ +export function resolveEffectiveTimeRange( + startTime?: string, + endTime?: string, + defaultRange: string = "12h", +): TimeRange { + if (startTime && endTime) { + return { startTime, endTime } + } + const resolved = relativeToAbsolute(defaultRange) + if (resolved) return resolved + return relativeToAbsolute("12h")! +} + /** * Returns effective time range, applying defaults when not specified. * @@ -24,13 +44,9 @@ export function useEffectiveTimeRange( const pageRefresh = useOptionalPageRefreshContext() const refreshVersion = pageRefresh?.refreshVersion ?? 0 - return useMemo(() => { - if (startTime && endTime) { - return { startTime, endTime } - } - const resolved = relativeToAbsolute(defaultRange) - if (resolved) return resolved - return relativeToAbsolute("12h")! + return useMemo( + () => resolveEffectiveTimeRange(startTime, endTime, defaultRange), // eslint-disable-next-line react-hooks/exhaustive-deps - }, [startTime, endTime, defaultRange, refreshVersion]) + [startTime, endTime, defaultRange, refreshVersion], + ) } diff --git a/apps/web/src/hooks/use-infinite-replays.ts b/apps/web/src/hooks/use-infinite-replays.ts index c3f25d4d0..bdd8323e0 100644 --- a/apps/web/src/hooks/use-infinite-replays.ts +++ b/apps/web/src/hooks/use-infinite-replays.ts @@ -7,7 +7,9 @@ import { listReplaysResultAtom } from "@/lib/services/atoms/warehouse-query-atom import type { SessionRow } from "@/components/replays/sessions-list" import { logClientError } from "@/lib/services/common/telemetry" -const PAGE_SIZE = 50 +/** Exported so the route loader prefetches the exact first-page key this hook reads. */ +export const REPLAYS_PAGE_SIZE = 50 +const PAGE_SIZE = REPLAYS_PAGE_SIZE export const MAX_RETAINED_REPLAYS = 500 /** diff --git a/apps/web/src/lib/services/common/atom-client.ts b/apps/web/src/lib/services/common/atom-client.ts index 61fb0a5be..35a088a1b 100644 --- a/apps/web/src/lib/services/common/atom-client.ts +++ b/apps/web/src/lib/services/common/atom-client.ts @@ -3,9 +3,16 @@ import { MapleApi } from "@maple/domain/http" import { Effect } from "effect" import { HttpClient, HttpClientError } from "effect/unstable/http" import { apiBaseUrl } from "./api-base-url" +import { hasCachedMapleAuthToken, invalidateMapleAuthToken } from "./auth-headers" import { MapleFetchHttpClientLive } from "./http-client" import { isRetryableTransportError, mapleRetrySchedule } from "./retry-policy" +/** + * Requests already given their one stale-token retry, keyed by request identity + * so concurrent 401s don't consume each other's allowance. + */ +const retriedUnauthorized = new WeakSet() + export class MapleApiAtomClient extends AtomHttpApi.Service()( "@maple/web/services/common/MapleApiAtomClient", { @@ -38,14 +45,26 @@ export class MapleApiAtomClient extends AtomHttpApi.Service( if (status === undefined) return false // Retry on 500/502/503 — not 504 (query timeout, won't get faster) if (status >= 500 && status < 600 && status !== 504) return true + if (status !== 401) return false + // A bearer token served from the auth-headers cache can go stale + // ahead of its own `exp` — revoked session, org switch, clock skew — + // and then 401s every request until it's dropped. Drop it and let + // exactly one retry go out with a freshly minted token; a second 401 + // means the failure is real, so it falls through and fails fast. + const request = (error as { request?: { url?: string } }).request + const firstAttempt = request !== undefined && !retriedUnauthorized.has(request) + if (firstAttempt) retriedUnauthorized.add(request) + if (firstAttempt && hasCachedMapleAuthToken()) { + invalidateMapleAuthToken() + return true + } // Billing reads (customer/usage/plans) can fire during the Clerk // token-settle window where getToken() is transiently null → the // request goes out unauthenticated → 401. Unlike the rest of the API // (which only mounts after auth settles), retry 401 *only* for the // billing endpoints so the data self-heals without a refresh. Scoped // by URL so a genuine auth failure elsewhere still fails fast. - const url = (error as { request?: { url?: string } }).request?.url - if (status === 401 && url?.includes("/api/billing/")) return true + if (request?.url?.includes("/api/billing/")) return true return false }, }), diff --git a/apps/web/src/lib/services/common/auth-headers.test.ts b/apps/web/src/lib/services/common/auth-headers.test.ts index f035f8ee2..757d05c9d 100644 --- a/apps/web/src/lib/services/common/auth-headers.test.ts +++ b/apps/web/src/lib/services/common/auth-headers.test.ts @@ -1,15 +1,27 @@ -import { beforeEach, describe, expect, it } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { clearMapleAuthHeaders, getMapleAuthHeaders, + hasCachedMapleAuthToken, + invalidateMapleAuthToken, + setActiveOrgId, setMapleAuthHeaders, setMapleAuthHeadersProvider, } from "./auth-headers" +/** A bearer JWT whose `exp` is `secondsFromNow` in the future. */ +const bearerExpiringIn = (secondsFromNow: number): string => { + const claims = { exp: Math.floor(Date.now() / 1000) + secondsFromNow } + const payload = btoa(JSON.stringify(claims)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + return `Bearer header.${payload}.signature` +} + describe("auth-headers", () => { beforeEach(() => { setMapleAuthHeadersProvider(undefined) clearMapleAuthHeaders() + setActiveOrgId(null) + invalidateMapleAuthToken() }) it("injects dynamic async auth headers", async () => { @@ -43,4 +55,95 @@ describe("auth-headers", () => { await expect(getMapleAuthHeaders()).resolves.toEqual({}) }) + + describe("bearer token cache", () => { + it("resolves a long-lived JWT once and serves the rest from cache", async () => { + const authorization = bearerExpiringIn(600) + const provider = vi.fn(async () => ({ authorization })) + setMapleAuthHeadersProvider(provider) + + await expect(getMapleAuthHeaders()).resolves.toEqual({ authorization }) + await expect(getMapleAuthHeaders()).resolves.toEqual({ authorization }) + await expect(getMapleAuthHeaders()).resolves.toEqual({ authorization }) + + expect(provider).toHaveBeenCalledTimes(1) + expect(hasCachedMapleAuthToken()).toBe(true) + }) + + it("collapses a concurrent burst into one provider call", async () => { + const provider = vi.fn(async () => ({ authorization: bearerExpiringIn(600) })) + setMapleAuthHeadersProvider(provider) + + await Promise.all([getMapleAuthHeaders(), getMapleAuthHeaders(), getMapleAuthHeaders()]) + + expect(provider).toHaveBeenCalledTimes(1) + }) + + it("re-resolves a token inside the expiry skew window", async () => { + // 5s of life left is under TOKEN_MIN_REMAINING_MS, so it must not be served. + const provider = vi.fn(async () => ({ authorization: bearerExpiringIn(5) })) + setMapleAuthHeadersProvider(provider) + + await getMapleAuthHeaders() + await getMapleAuthHeaders() + + expect(provider).toHaveBeenCalledTimes(2) + }) + + it("never caches an opaque (non-JWT) token", async () => { + const provider = vi.fn(async () => ({ authorization: "Bearer opaque-self-hosted-token" })) + setMapleAuthHeadersProvider(provider) + + await getMapleAuthHeaders() + await getMapleAuthHeaders() + + expect(provider).toHaveBeenCalledTimes(2) + expect(hasCachedMapleAuthToken()).toBe(false) + }) + + it("drops the cached token on org switch, so no request carries the old org", async () => { + const provider = vi.fn(async () => ({ authorization: bearerExpiringIn(600) })) + setMapleAuthHeadersProvider(provider) + await getMapleAuthHeaders() + expect(provider).toHaveBeenCalledTimes(1) + + setActiveOrgId("org_second") + + expect(hasCachedMapleAuthToken()).toBe(false) + await getMapleAuthHeaders() + expect(provider).toHaveBeenCalledTimes(2) + }) + + it("discards a refresh that resolves after the identity changed", async () => { + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + setMapleAuthHeadersProvider(async () => { + await gate + return { authorization: bearerExpiringIn(600) } + }) + + const pending = getMapleAuthHeaders() + setActiveOrgId("org_switched_mid_flight") + release?.() + await pending + + // The in-flight token belonged to the previous org — it must not populate + // the cache for the new one. + expect(hasCachedMapleAuthToken()).toBe(false) + }) + + it("drops the cached token on sign-out", async () => { + setMapleAuthHeadersProvider(async () => ({ authorization: bearerExpiringIn(600) })) + await getMapleAuthHeaders() + expect(hasCachedMapleAuthToken()).toBe(true) + + setMapleAuthHeadersProvider(undefined) + clearMapleAuthHeaders() + + expect(hasCachedMapleAuthToken()).toBe(false) + await expect(getMapleAuthHeaders()).resolves.toEqual({}) + }) + }) }) diff --git a/apps/web/src/lib/services/common/auth-headers.ts b/apps/web/src/lib/services/common/auth-headers.ts index fcf65cab3..58965a6c8 100644 --- a/apps/web/src/lib/services/common/auth-headers.ts +++ b/apps/web/src/lib/services/common/auth-headers.ts @@ -5,6 +5,92 @@ type MapleAuthHeadersProvider = () => Promise | MapleAuthHeade let authHeaders: MapleAuthHeaders = {} let authHeadersProvider: MapleAuthHeadersProvider | undefined +// --------------------------------------------------------------------------- +// Bearer-token cache +// +// Every outbound API request awaits this module (see http-client.ts), and the +// Clerk provider below resolves to `getToken()` — a cross-origin round-trip to +// Clerk whenever the 60s session JWT is near expiry. Uncached, that landed on +// the critical path of every request in the app: production traces showed p50 +// 580ms / p90 3.2s elapsing between the browser issuing a request and the API +// Worker's server span starting, against a ~200ms handler. +// +// So: cache the resolved headers until the token is nearly spent, and refresh +// ahead of that so no request ever blocks on the identity provider. Only +// JWT-shaped bearer tokens are cached — an opaque token has no expiry we can +// trust, and the providers that issue one (self-hosted, reading sessionStorage) +// are synchronous anyway. +// --------------------------------------------------------------------------- + +/** Inside this much remaining life, a request must wait for a fresh token. */ +const TOKEN_MIN_REMAINING_MS = 10_000 +/** Inside this much, serve the cached token but refresh in the background. */ +const TOKEN_REFRESH_AHEAD_MS = 30_000 + +interface CachedAuth { + readonly headers: MapleAuthHeaders + readonly expMs: number +} + +let cachedAuth: CachedAuth | undefined +let inFlightRefresh: Promise | undefined +/** + * Bumped whenever the identity changes (provider swap, sign-out, org switch). + * A refresh started under an older generation must not populate the cache — its + * token belongs to an identity we've since left. + */ +let authGeneration = 0 + +const decodeBase64Url = (segment: string): string => { + const padded = segment.replace(/-/g, "+").replace(/_/g, "/") + return atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), "=")) +} + +/** Expiry of a bearer JWT in epoch ms, or undefined if it isn't one. */ +const readBearerExpMs = (headers: MapleAuthHeaders): number | undefined => { + const authorization = headers.authorization + if (typeof authorization !== "string" || !authorization.startsWith("Bearer ")) return undefined + const segments = authorization.slice("Bearer ".length).split(".") + if (segments.length !== 3 || segments[1] === undefined) return undefined + try { + const claims: unknown = JSON.parse(decodeBase64Url(segments[1])) + if (typeof claims !== "object" || claims === null) return undefined + const exp = (claims as { exp?: unknown }).exp + return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : undefined + } catch { + // Not a JWT we can read — fall through to resolving on every call. + return undefined + } +} + +const refreshAuthHeaders = (): Promise => { + if (inFlightRefresh) return inFlightRefresh + const provider = authHeadersProvider + if (!provider) return Promise.resolve({}) + const generation = authGeneration + inFlightRefresh = Promise.resolve(provider()) + .then((headers) => { + if (generation === authGeneration) { + const expMs = readBearerExpMs(headers) + cachedAuth = expMs === undefined ? undefined : { headers, expMs } + } + return headers + }) + .finally(() => { + inFlightRefresh = undefined + }) + return inFlightRefresh +} + +/** Drop the cached bearer token so the next request re-resolves it. */ +export const invalidateMapleAuthToken = () => { + authGeneration += 1 + cachedAuth = undefined +} + +/** Whether a bearer token is currently being served from cache. */ +export const hasCachedMapleAuthToken = (): boolean => cachedAuth !== undefined + // The active org isn't carried in the auth headers — it's implicit in the // Clerk/self-hosted bearer token, so the API derives it server-side. Client-side // caches that must not bleed across orgs (e.g. SpanMetrics availability) can't @@ -20,6 +106,10 @@ export const setActiveOrgId = (orgId: string | null | undefined) => { const next = orgId && orgId.length > 0 ? orgId : null if (next === activeOrgId) return activeOrgId = next + // The bearer token encodes the active org, so a cached one that outlived an + // org switch would query the previous org's data. Drop it here rather than + // relying on Clerk to have re-issued before the next request goes out. + invalidateMapleAuthToken() // Notify reactive consumers (e.g. useActiveOrgId → the per-org ElectricSQL // collection lifecycle) so an org switch recreates org-scoped state. for (const notify of activeOrgSubscribers) notify() @@ -32,7 +122,21 @@ export const subscribeActiveOrgId = (notify: () => void): (() => void) => { } export const getMapleAuthHeaders = async (): Promise => { - const providedHeaders = authHeadersProvider ? await authHeadersProvider() : {} + const cached = cachedAuth + const remainingMs = cached === undefined ? -1 : cached.expMs - Date.now() + let providedHeaders: MapleAuthHeaders + if (cached !== undefined && remainingMs > TOKEN_MIN_REMAINING_MS) { + providedHeaders = cached.headers + if (remainingMs <= TOKEN_REFRESH_AHEAD_MS) { + // Refresh ahead of the deadline. Deliberately not awaited: this request + // already has a valid token, and blocking it on Clerk is the cost this + // cache exists to remove. A failed refresh just leaves the cache alone + // for the next caller to retry. + void refreshAuthHeaders().catch(() => undefined) + } + } else { + providedHeaders = authHeadersProvider ? await refreshAuthHeaders() : {} + } return { ...providedHeaders, ...authHeaders, @@ -45,8 +149,10 @@ export const setMapleAuthHeaders = (headers: Record) => { export const clearMapleAuthHeaders = () => { authHeaders = {} + invalidateMapleAuthToken() } export const setMapleAuthHeadersProvider = (provider?: MapleAuthHeadersProvider) => { authHeadersProvider = provider + invalidateMapleAuthToken() } diff --git a/apps/web/src/routes/replays/index.tsx b/apps/web/src/routes/replays/index.tsx index 56b2389fd..b62b6ca3a 100644 --- a/apps/web/src/routes/replays/index.tsx +++ b/apps/web/src/routes/replays/index.tsx @@ -8,10 +8,10 @@ import { ActiveUserFilter } from "@/components/replays/active-user-filter" import { ReplaysFilterSidebar } from "@/components/replays/replays-filter-sidebar" import { ReplaysToolbar } from "@/components/replays/replays-toolbar" import { BooleanFromStringParam, NumberFromStringParam } from "@/lib/search-params" -import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" -import { useInfiniteReplays } from "@/hooks/use-infinite-replays" +import { replaysFilterInputs } from "@/components/replays/replays-filter-inputs" +import { REPLAYS_PAGE_SIZE, useInfiniteReplays } from "@/hooks/use-infinite-replays" import { Result, useAtomValue } from "@/lib/effect-atom" -import { replaysFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { listReplaysResultAtom, replaysFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" @@ -43,38 +43,31 @@ const replaysSearchSchema = Schema.Struct({ export const Route = createFileRoute("/replays/")({ component: ReplaysPage, validateSearch: Schema.toStandardSchemaV1(replaysSearchSchema), + loaderDeps: ({ search }) => search, + // Both queries are on the critical path and neither is cached server-side, so + // starting them here rather than on mount is worth real time: the router runs + // `defaultPreload: "intent"`, which fires this on hover — ahead of the route + // chunk evaluating and React committing. Mount is fire-and-forget; the + // component reads the same entries and renders its skeleton meanwhile. + loader: ({ context, deps }) => { + const filterInputs = replaysFilterInputs(deps) + context.effectRegistry.mount( + listReplaysResultAtom({ data: { ...filterInputs, limit: REPLAYS_PAGE_SIZE, offset: 0 } }), + ) + context.effectRegistry.mount(replaysFacetsResultAtom({ data: filterInputs })) + }, }) function ReplaysPage() { const search = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) - const { startTime, endTime } = useEffectiveTimeRange( - search.startTime, - search.endTime, - search.timePreset ?? "24h", - ) const filterInputs = useMemo( - () => ({ - startTime, - endTime, - serviceName: search.service, - browser: search.browser, - country: search.country, - deviceType: search.deviceType, - userId: search.userId, - visitorId: search.visitorId, - hasErrors: search.hasErrors, - search: search.q, - // URL params are whole seconds; the warehouse filters in ms. - durationMinMs: search.durationMin != null ? search.durationMin * 1000 : undefined, - durationMaxMs: search.durationMax != null ? search.durationMax * 1000 : undefined, - activeTimeMinMs: search.activeMin != null ? search.activeMin * 1000 : undefined, - activeTimeMaxMs: search.activeMax != null ? search.activeMax * 1000 : undefined, - }), + () => replaysFilterInputs(search), [ - startTime, - endTime, + search.startTime, + search.endTime, + search.timePreset, search.service, search.browser, search.country, @@ -89,6 +82,7 @@ function ReplaysPage() { search.activeMax, ], ) + const { startTime, endTime } = filterInputs const { firstPageResult, allData, hasNextPage, isCapped, isFetchingNextPage, fetchNextPage } = useInfiniteReplays(filterInputs)