@iann29/rastro 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +80 -36
  2. package/agent/manifest.json +2 -2
  3. package/dist/component/_generated/component.d.ts +1 -0
  4. package/dist/component/_generated/component.d.ts.map +1 -1
  5. package/dist/component/_generated/server.d.ts +1 -0
  6. package/dist/component/_generated/server.d.ts.map +1 -1
  7. package/dist/component/_generated/server.js.map +1 -1
  8. package/dist/component/constants.d.ts +7 -2
  9. package/dist/component/constants.d.ts.map +1 -1
  10. package/dist/component/constants.js +24 -3
  11. package/dist/component/constants.js.map +1 -1
  12. package/dist/component/convex.config.d.ts +1 -0
  13. package/dist/component/convex.config.js +3 -0
  14. package/dist/component/convex.config.js.map +1 -1
  15. package/dist/component/geo.d.ts +1 -0
  16. package/dist/component/geo.d.ts.map +1 -1
  17. package/dist/component/geo.js +1 -1
  18. package/dist/component/geo.js.map +1 -1
  19. package/dist/component/http.d.ts.map +1 -1
  20. package/dist/component/http.js +14 -1
  21. package/dist/component/http.js.map +1 -1
  22. package/dist/component/identity.d.ts +12 -0
  23. package/dist/component/identity.d.ts.map +1 -1
  24. package/dist/component/identity.js +30 -2
  25. package/dist/component/identity.js.map +1 -1
  26. package/dist/component/ingest.d.ts +34 -0
  27. package/dist/component/ingest.d.ts.map +1 -1
  28. package/dist/component/ingest.js +69 -17
  29. package/dist/component/ingest.js.map +1 -1
  30. package/dist/component/live.d.ts.map +1 -1
  31. package/dist/component/live.js +34 -4
  32. package/dist/component/live.js.map +1 -1
  33. package/dist/component/reports.d.ts.map +1 -1
  34. package/dist/component/reports.js +21 -6
  35. package/dist/component/reports.js.map +1 -1
  36. package/dist/component/rollupStore.d.ts +7 -4
  37. package/dist/component/rollupStore.d.ts.map +1 -1
  38. package/dist/component/rollupStore.js +37 -17
  39. package/dist/component/rollupStore.js.map +1 -1
  40. package/dist/component/rollups.d.ts +27 -8
  41. package/dist/component/rollups.d.ts.map +1 -1
  42. package/dist/component/rollups.js +110 -32
  43. package/dist/component/rollups.js.map +1 -1
  44. package/dist/component/schema.d.ts +6 -2
  45. package/dist/component/schema.js +5 -2
  46. package/dist/component/schema.js.map +1 -1
  47. package/dist/tracker/generated.d.ts +11 -11
  48. package/dist/tracker/generated.d.ts.map +1 -1
  49. package/dist/tracker/generated.js +11 -11
  50. package/dist/tracker/generated.js.map +1 -1
  51. package/dist/tracker/tracker.js +88 -43
  52. package/dist/tracker/tracker.js.map +1 -1
  53. package/dist/tracker/vitals.d.ts.map +1 -1
  54. package/dist/tracker/vitals.js +22 -4
  55. package/dist/tracker/vitals.js.map +1 -1
  56. package/dist/tracker.min.js +1 -1
  57. package/dist/vitals.min.js +1 -1
  58. package/docs/upgrading.md +84 -28
  59. package/llms.txt +3 -3
  60. package/package.json +1 -1
  61. package/src/component/_generated/component.ts +1 -0
  62. package/src/component/_generated/server.ts +1 -0
  63. package/src/component/constants.ts +24 -3
  64. package/src/component/convex.config.ts +3 -0
  65. package/src/component/geo.ts +1 -1
  66. package/src/component/http.ts +15 -1
  67. package/src/component/identity.ts +41 -2
  68. package/src/component/ingest.ts +92 -32
  69. package/src/component/live.ts +51 -4
  70. package/src/component/reports.ts +24 -8
  71. package/src/component/rollupStore.ts +51 -20
  72. package/src/component/rollups.ts +141 -32
  73. package/src/component/schema.ts +6 -3
  74. package/src/tracker/generated.ts +11 -11
@@ -4,9 +4,20 @@ export const MAX_BATCH_BYTES = 64 * 1024;
4
4
  export const MAX_EVENTS_PER_SESSION_WINDOW = 120;
5
5
  export const RATE_LIMIT_WINDOW_MS = 60_000;
6
6
  export const MAX_BYTES_PER_SESSION_WINDOW = 256 * 1024;
7
- export const SITE_INGEST_SHARDS = 4_096;
8
- export const MAX_SITE_EVENTS_PER_SHARD_WINDOW = 300;
9
- export const MAX_SITE_BYTES_PER_SHARD_WINDOW = 512 * 1024;
7
+ // A site admits at most SITE_INGEST_SHARDS × the per-shard caps a minute:
8
+ // 384,000 events and 512 MiB. Shards keep every POST off one shared row per
9
+ // site; random session IDs can only fill them evenly, while a real peak lands
10
+ // unevenly — the realistic benchmark (~100k events/min) peaks near 1,050
11
+ // events in its hottest shard-minute, so fewer shards would need a looser cap
12
+ // per shard and more would need a looser cap per site.
13
+ export const SITE_INGEST_SHARDS = 256;
14
+ export const MAX_SITE_EVENTS_PER_SHARD_WINDOW = 1_500;
15
+ export const MAX_SITE_BYTES_PER_SHARD_WINDOW = 2 * 1024 * 1024;
16
+ // Per client network (an IPv4 address or an IPv6 /64, keyed by a salted daily
17
+ // hash) per site and minute. Generous on purpose: an office or carrier-grade
18
+ // NAT puts many visitors behind one address, and they all share this budget.
19
+ export const MAX_EVENTS_PER_CLIENT_WINDOW = 1_200;
20
+ export const MAX_BYTES_PER_CLIENT_WINDOW = 2 * 1024 * 1024;
10
21
  export const LIVE_SESSION_TTL_MS = 90_000;
11
22
  export const LIVE_LEAVE_GRACE_MS = 10_000;
12
23
  export const LIVE_SWEEP_INTERVAL_MS = 5_000;
@@ -44,6 +55,16 @@ export const RETENTION_LEASE_MS = 60_000;
44
55
  export const ROLLUP_FOLD_BATCH_DOCS = 200;
45
56
  export const ROLLUP_FOLD_MAX_ROWS = 1_500;
46
57
  export const ROLLUP_FOLD_LEASE_MS = 60_000;
58
+ // A batch that finds no fold pending schedules one this far out, and a run
59
+ // that folded anything schedules the next one as far again, so one run per
60
+ // second serves every batch of that second; only a run that finds the queue
61
+ // empty releases the lease.
62
+ export const ROLLUP_FOLD_DEBOUNCE_MS = 1_000;
63
+ // A run that throws retries its oldest batch alone; a batch that fails this
64
+ // many runs in a row is set aside as a dead letter, kept this long (30 days)
65
+ // for inspection, so one bad batch cannot stall a site's reports.
66
+ export const ROLLUP_FOLD_MAX_FAILURES = 3;
67
+ export const ROLLUP_DEAD_LETTER_RETENTION_MS = 30 * 86_400_000;
47
68
  // Shared Web Vitals histogram edges. Every Google rating threshold (LCP
48
69
  // 2500/4000, CLS×1000 100/250, INP 200/500, FCP 1800/3000, TTFB 800/1800) is
49
70
  // an edge, so rating counts stay exact while one layout serves all metrics.
@@ -9,5 +9,8 @@ export default defineComponent("rastroAnalytics", {
9
9
  RASTRO_GEOIP_TOKEN: v.optional(v.string()),
10
10
  RASTRO_GEOIP_DAILY_LIMIT: v.optional(v.string()),
11
11
  RASTRO_TRUST_PROXY: v.optional(v.literal("true")),
12
+ // "off" drops the per-client ingest budget, for a load-test canary whose
13
+ // driver sends every request from one address.
14
+ RASTRO_CLIENT_RATE_LIMIT: v.optional(v.literal("off")),
12
15
  },
13
16
  });
@@ -510,7 +510,7 @@ function classifyIpv6(ip: string): Exclude<IpKind, "unavailable"> {
510
510
  return (groups[0]! & 0xe000) === 0x2000 ? "public" : "private";
511
511
  }
512
512
 
513
- function parseIpv6(ip: string): number[] | undefined {
513
+ export function parseIpv6(ip: string): number[] | undefined {
514
514
  let normalized = ip;
515
515
  const dottedTail = normalized.match(/(\d+\.\d+\.\d+\.\d+)$/)?.[1];
516
516
  if (dottedTail) {
@@ -20,7 +20,7 @@ import {
20
20
  } from "./geo.js";
21
21
  import { isPlainRecord } from "./guards.js";
22
22
  import { alignClock, sanitizeEvent } from "./sanitize.js";
23
- import { deriveVisitorKey, randomSecret } from "./identity.js";
23
+ import { deriveClientKey, deriveVisitorKey, randomSecret } from "./identity.js";
24
24
  import { classifyClient, isKnownBot } from "./useragent.js";
25
25
  import type { TrackerEvent } from "./validators.js";
26
26
  import {
@@ -179,6 +179,19 @@ http.route({
179
179
  ...classifyClient(userAgent, request.headers),
180
180
  ...(visitorKey ? { visitorKey } : {}),
181
181
  };
182
+ // Origin is a browser control: any other sender writes the header it
183
+ // likes. The per-client budget is keyed like the visitor key above (salted,
184
+ // daily, never the address); without a usable client IP only the per-site
185
+ // shards bound the batch.
186
+ const clientKey =
187
+ clientIp.ip && env.RASTRO_CLIENT_RATE_LIMIT !== "off"
188
+ ? await deriveClientKey({
189
+ secret: await visitorSecret(ctx),
190
+ now: Date.now(),
191
+ siteId: payload.siteId,
192
+ ip: clientIp.ip,
193
+ })
194
+ : undefined;
182
195
 
183
196
  try {
184
197
  const result = await ctx.runMutation(api.ingest.ingestBatch, {
@@ -186,6 +199,7 @@ http.route({
186
199
  origin,
187
200
  events,
188
201
  context,
202
+ ...(clientKey ? { clientKey } : {}),
189
203
  });
190
204
  if (
191
205
  clientIp.ip &&
@@ -1,5 +1,6 @@
1
1
  import { v } from "convex/values";
2
2
  import { DAY_MS } from "./constants.js";
3
+ import { parseIpv6 } from "./geo.js";
3
4
  import { internalMutation, internalQuery } from "./_generated/server.js";
4
5
 
5
6
  /**
@@ -57,9 +58,47 @@ export async function deriveVisitorKey(input: {
57
58
  ip: string;
58
59
  userAgent: string;
59
60
  }): Promise<string> {
60
- const dayStart = Math.floor(input.now / DAY_MS) * DAY_MS;
61
+ return saltedDailyKey(input.secret, input.now, [
62
+ input.siteId,
63
+ input.ip,
64
+ input.userAgent,
65
+ ]);
66
+ }
67
+
68
+ /**
69
+ * The per-client ingest budget's key: the same salted daily hash, over the
70
+ * client's network instead of its device — the IPv4 address, or the /64 of an
71
+ * IPv6 address, the block one subscriber gets and could otherwise walk through
72
+ * one address per request.
73
+ */
74
+ export async function deriveClientKey(input: {
75
+ secret: string;
76
+ now: number;
77
+ siteId: string;
78
+ ip: string;
79
+ }): Promise<string> {
80
+ const groups = parseIpv6(input.ip.toLowerCase());
81
+ const network = groups
82
+ ? `${groups
83
+ .slice(0, 4)
84
+ .map((group) => group.toString(16))
85
+ .join(":")}::/64`
86
+ : input.ip;
87
+ return saltedDailyKey(input.secret, input.now, [
88
+ "client",
89
+ input.siteId,
90
+ network,
91
+ ]);
92
+ }
93
+
94
+ async function saltedDailyKey(
95
+ secret: string,
96
+ now: number,
97
+ parts: string[],
98
+ ): Promise<string> {
99
+ const dayStart = Math.floor(now / DAY_MS) * DAY_MS;
61
100
  const material = new TextEncoder().encode(
62
- [input.secret, dayStart, input.siteId, input.ip, input.userAgent].join(" "),
101
+ [secret, dayStart, ...parts].join(" "),
63
102
  );
64
103
  const digest = new Uint8Array(
65
104
  await crypto.subtle.digest("SHA-256", material),
@@ -14,6 +14,8 @@ import {
14
14
  LIVE_SESSION_TTL_MS,
15
15
  LIVE_SWEEP_INTERVAL_MS,
16
16
  MAX_BATCH_EVENTS,
17
+ MAX_BYTES_PER_CLIENT_WINDOW,
18
+ MAX_EVENTS_PER_CLIENT_WINDOW,
17
19
  MAX_SITE_BYTES_PER_SHARD_WINDOW,
18
20
  MAX_SITE_EVENTS_PER_SHARD_WINDOW,
19
21
  MAX_BYTES_PER_SESSION_WINDOW,
@@ -123,7 +125,7 @@ type AggregateDelta = {
123
125
  continuedSessions: number;
124
126
  newVisitors: number;
125
127
  };
126
- type AggregateDeltas = Map<string, AggregateDelta>;
128
+ export type AggregateDeltas = Map<string, AggregateDelta>;
127
129
  type AttributionSnapshot = {
128
130
  affiliateId: Id<"affiliates">;
129
131
  affiliateSlug: string;
@@ -214,6 +216,8 @@ export const ingestBatch = mutation({
214
216
  origin: v.string(),
215
217
  events: v.array(trackerEventValidator),
216
218
  context: v.optional(ingestContextValidator),
219
+ // The HTTP action's salted daily client-network key (see identity.ts).
220
+ clientKey: v.optional(v.string()),
217
221
  },
218
222
  returns: ingestResultValidator,
219
223
  handler: async (ctx, args) => {
@@ -245,6 +249,15 @@ export const ingestBatch = mutation({
245
249
 
246
250
  const now = Date.now();
247
251
  await ensureAnalyticsControl(ctx, args.siteId, now);
252
+ if (args.clientKey !== undefined) {
253
+ await consumeClientRateLimit(
254
+ ctx,
255
+ args.siteId,
256
+ args.clientKey,
257
+ eventByteCounts,
258
+ now,
259
+ );
260
+ }
248
261
  await consumeSiteRateLimit(
249
262
  ctx,
250
263
  args.siteId,
@@ -389,7 +402,10 @@ export const ingestBatch = mutation({
389
402
  rejected += 1;
390
403
  continue;
391
404
  }
392
- const durationDeltaMs = await accountHeartbeat(
405
+ // A heartbeat is presence, not telemetry: it moves the session's
406
+ // lastSeenAt and live row only. The time it proves reaches the
407
+ // duration counters once, when the visitor departs (live.ts).
408
+ await accountHeartbeat(
393
409
  ctx,
394
410
  args.siteId,
395
411
  existingSession,
@@ -398,16 +414,6 @@ export const ingestBatch = mutation({
398
414
  now,
399
415
  batchState,
400
416
  );
401
- // A heartbeat is presence, not telemetry, but it is what keeps a
402
- // session's duration honest while the visitor reads: the growth is
403
- // folded into the buckets the heartbeat landed in.
404
- foldSessionDuration(
405
- aggregateDeltas,
406
- args.siteId,
407
- event,
408
- durationDeltaMs,
409
- timezone,
410
- );
411
417
  accepted += 1;
412
418
  continue;
413
419
  }
@@ -461,7 +467,8 @@ export const ingestBatch = mutation({
461
467
  foldSessionDuration(
462
468
  aggregateDeltas,
463
469
  args.siteId,
464
- event,
470
+ event.sessionId,
471
+ event.timestamp,
465
472
  durationMs - existingSession.durationMs,
466
473
  timezone,
467
474
  );
@@ -1421,6 +1428,12 @@ async function attributionForEvent(
1421
1428
  };
1422
1429
  }
1423
1430
 
1431
+ /**
1432
+ * Moves the session's lastSeenAt and its live row. `durationMs` stays: it is
1433
+ * what the duration counters already hold, and the time a heartbeat proves
1434
+ * is counted when the visitor departs (live.ts), so an idle tab never
1435
+ * rewrites an aggregate bucket.
1436
+ */
1424
1437
  async function accountHeartbeat(
1425
1438
  ctx: MutationCtx,
1426
1439
  siteId: Id<"sites">,
@@ -1429,7 +1442,7 @@ async function accountHeartbeat(
1429
1442
  context: IngestContext,
1430
1443
  now: number,
1431
1444
  batchState?: BatchState,
1432
- ): Promise<number> {
1445
+ ): Promise<void> {
1433
1446
  const startedAt = Math.min(session.startedAt, event.timestamp);
1434
1447
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
1435
1448
  const updates = {
@@ -1437,7 +1450,6 @@ async function accountHeartbeat(
1437
1450
  lastSeenAt,
1438
1451
  exitPath:
1439
1452
  event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
1440
- durationMs: Math.max(0, lastSeenAt - startedAt),
1441
1453
  ...(context.country ? { country: context.country } : {}),
1442
1454
  ...(context.city ? { city: context.city } : {}),
1443
1455
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
@@ -1456,7 +1468,6 @@ async function accountHeartbeat(
1456
1468
  } else {
1457
1469
  await ctx.db.patch("sessions", session._id, updates);
1458
1470
  }
1459
- const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
1460
1471
  await updateLiveSession(
1461
1472
  ctx,
1462
1473
  siteId,
@@ -1486,7 +1497,54 @@ async function accountHeartbeat(
1486
1497
  now,
1487
1498
  batchState,
1488
1499
  );
1489
- return durationDeltaMs;
1500
+ }
1501
+
1502
+ /**
1503
+ * One client network's budget per site and minute, kept in the session
1504
+ * windows' table under `~client:<key>`, a key no sanitized session ID can take.
1505
+ * The key is the salted daily hash the HTTP action derived; the address never
1506
+ * reaches this mutation. It is one row per client, so only that client's own
1507
+ * concurrent requests contend on it.
1508
+ */
1509
+ async function consumeClientRateLimit(
1510
+ ctx: MutationCtx,
1511
+ siteId: Id<"sites">,
1512
+ clientKey: string,
1513
+ eventByteCounts: number[],
1514
+ now: number,
1515
+ ): Promise<void> {
1516
+ const windowStart =
1517
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1518
+ const sessionId = `~client:${clientKey}`;
1519
+ const rows = await ctx.db
1520
+ .query("ingestWindows")
1521
+ .withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
1522
+ range.eq("siteId", siteId).eq("sessionId", sessionId),
1523
+ )
1524
+ .order("desc")
1525
+ .take(1);
1526
+ const window = rows[0] ?? null;
1527
+ const current = window?.windowStart === windowStart ? window : null;
1528
+ const eventCount = (current?.eventCount ?? 0) + eventByteCounts.length;
1529
+ const byteCount =
1530
+ (current?.byteCount ?? 0) +
1531
+ eventByteCounts.reduce((total, bytes) => total + bytes, 0);
1532
+ if (
1533
+ eventCount > MAX_EVENTS_PER_CLIENT_WINDOW ||
1534
+ byteCount > MAX_BYTES_PER_CLIENT_WINDOW
1535
+ ) {
1536
+ fail("RATE_LIMITED", "client ingest budget exceeded", {
1537
+ windowMs: RATE_LIMIT_WINDOW_MS,
1538
+ eventLimitPerClient: MAX_EVENTS_PER_CLIENT_WINDOW,
1539
+ byteLimitPerClient: MAX_BYTES_PER_CLIENT_WINDOW,
1540
+ });
1541
+ }
1542
+ const fields = { windowStart, eventCount, byteCount, updatedAt: now };
1543
+ if (window) {
1544
+ await ctx.db.patch("ingestWindows", window._id, fields);
1545
+ } else {
1546
+ await ctx.db.insert("ingestWindows", { siteId, sessionId, ...fields });
1547
+ }
1490
1548
  }
1491
1549
 
1492
1550
  async function consumeSiteRateLimit(
@@ -1872,11 +1930,10 @@ async function advanceFunnel(
1872
1930
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1873
1931
  return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1874
1932
  }
1875
- if (
1876
- progress.completedAt !== undefined ||
1877
- event.timestamp < progress.lastStepAt
1878
- )
1879
- return;
1933
+ if (event.timestamp < progress.lastStepAt) return;
1934
+ // An expired window restarts on a first-step event even after a completion,
1935
+ // so a returning buyer can complete again; inside the window a completed
1936
+ // funnel stays done.
1880
1937
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1881
1938
  if (!matchesFirst) return;
1882
1939
  const updated = {
@@ -1910,6 +1967,7 @@ async function advanceFunnel(
1910
1967
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1911
1968
  return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1912
1969
  }
1970
+ if (progress.completedAt !== undefined) return;
1913
1971
  const nextStep = funnel.steps[progress.currentStep];
1914
1972
  if (!nextStep || !stepMatches(nextStep, event)) return;
1915
1973
  const currentStep = progress.currentStep + 1;
@@ -2428,24 +2486,26 @@ function aggregateBucketStart(
2428
2486
  return Math.floor(timestamp / interval) * interval;
2429
2487
  }
2430
2488
 
2431
- /** Folds session duration growth without telemetry, for heartbeats and leaves. */
2432
- function foldSessionDuration(
2489
+ /**
2490
+ * Folds session duration growth without telemetry: a departure's final
2491
+ * interval, or the heartbeat time of a visitor the live sweep retired. The
2492
+ * shard follows `shardKey`: the session for a departure, the site for a
2493
+ * sweep.
2494
+ */
2495
+ export function foldSessionDuration(
2433
2496
  aggregateDeltas: AggregateDeltas,
2434
2497
  siteId: Id<"sites">,
2435
- event: TrackerEvent,
2498
+ shardKey: string,
2499
+ timestamp: number,
2436
2500
  durationDeltaMs: number,
2437
2501
  timezone?: string,
2438
2502
  ) {
2439
2503
  if (durationDeltaMs <= 0) return;
2440
2504
  for (const granularity of aggregateGranularities(timezone)) {
2441
2505
  const shard =
2442
- stableHash(event.sessionId) %
2506
+ stableHash(shardKey) %
2443
2507
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
2444
- const bucketStart = aggregateBucketStart(
2445
- granularity,
2446
- event.timestamp,
2447
- timezone,
2448
- );
2508
+ const bucketStart = aggregateBucketStart(granularity, timestamp, timezone);
2449
2509
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
2450
2510
  const delta = aggregateDeltas.get(key) ?? {
2451
2511
  siteId,
@@ -2472,7 +2532,7 @@ function foldSessionDuration(
2472
2532
  }
2473
2533
  }
2474
2534
 
2475
- async function flushAggregates(
2535
+ export async function flushAggregates(
2476
2536
  ctx: MutationCtx,
2477
2537
  aggregateDeltas: AggregateDeltas,
2478
2538
  ) {
@@ -1,7 +1,15 @@
1
1
  import { v } from "convex/values";
2
2
  import { internal } from "./_generated/api.js";
3
+ import type { Doc, Id } from "./_generated/dataModel.js";
4
+ import type { MutationCtx } from "./_generated/server.js";
3
5
  import { internalMutation } from "./_generated/server.js";
4
6
  import { LIVE_SWEEP_INTERVAL_MS } from "./constants.js";
7
+ import {
8
+ flushAggregates,
9
+ foldSessionDuration,
10
+ type AggregateDeltas,
11
+ } from "./ingest.js";
12
+ import { localDayTimezone } from "./localTime.js";
5
13
 
6
14
  const SWEEP_BATCH_SIZE = 500;
7
15
 
@@ -58,7 +66,7 @@ export const expire = internalMutation({
58
66
  }
59
67
  return { expired: false, rescheduled: false, stale: false };
60
68
  }
61
- await ctx.db.delete("liveSessions", liveSession._id);
69
+ await retireLiveSessions(ctx, liveSession.siteId, [liveSession]);
62
70
  return { expired: true, rescheduled: false, stale: false };
63
71
  },
64
72
  });
@@ -89,9 +97,7 @@ export const sweep = internalMutation({
89
97
  range.eq("siteId", args.siteId).lte("expiresAt", now),
90
98
  )
91
99
  .take(SWEEP_BATCH_SIZE);
92
- for (const session of expired) {
93
- await ctx.db.delete("liveSessions", session._id);
94
- }
100
+ await retireLiveSessions(ctx, args.siteId, expired);
95
101
 
96
102
  const next = await ctx.db
97
103
  .query("liveSessions")
@@ -115,3 +121,44 @@ export const sweep = internalMutation({
115
121
  return { expired: expired.length, rescheduled: true, stale: false };
116
122
  },
117
123
  });
124
+
125
+ /**
126
+ * Deletes departed live rows and counts the time their heartbeats proved. A
127
+ * heartbeat only moves its session's `lastSeenAt`, and `durationMs` is what
128
+ * the duration counters already hold, so the difference reaches the buckets
129
+ * here, once, in the hour the visitor was last seen. It lands in one shard
130
+ * per site, so a sweep rewrites a few buckets however many visitors it
131
+ * retires.
132
+ */
133
+ async function retireLiveSessions(
134
+ ctx: MutationCtx,
135
+ siteId: Id<"sites">,
136
+ rows: Doc<"liveSessions">[],
137
+ ) {
138
+ if (rows.length === 0) return;
139
+ const site = await ctx.db.get("sites", siteId);
140
+ const timezone = localDayTimezone(site?.timezone);
141
+ const aggregateDeltas: AggregateDeltas = new Map();
142
+ for (const row of rows) {
143
+ await ctx.db.delete("liveSessions", row._id);
144
+ const session = await ctx.db
145
+ .query("sessions")
146
+ .withIndex("by_siteId_and_sessionId", (range) =>
147
+ range.eq("siteId", siteId).eq("sessionId", row.sessionId),
148
+ )
149
+ .unique();
150
+ if (!session) continue;
151
+ const durationMs = session.lastSeenAt - session.startedAt;
152
+ if (durationMs <= session.durationMs) continue;
153
+ await ctx.db.patch("sessions", session._id, { durationMs });
154
+ foldSessionDuration(
155
+ aggregateDeltas,
156
+ siteId,
157
+ siteId,
158
+ session.lastSeenAt,
159
+ durationMs - session.durationMs,
160
+ timezone,
161
+ );
162
+ }
163
+ await flushAggregates(ctx, aggregateDeltas);
164
+ }
@@ -346,14 +346,19 @@ export const overview = query({
346
346
  const bucketCount = localDays
347
347
  ? bucketStarts.length
348
348
  : Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
349
- const perSiteLimit =
350
- bucketCount *
351
- (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
349
+ const shards =
350
+ interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS;
351
+ const perSiteLimit = bucketCount * shards;
352
352
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
353
+ // The row budget, not MAX_REPORT_RANGE_DAYS, bounds the overview: 101
354
+ // days for one site and 10 for ten; 24 hours for two, 5 for ten.
355
+ const maximumBuckets = Math.floor(
356
+ MAX_OVERVIEW_AGGREGATE_ROWS / (shards * siteIds.length),
357
+ );
353
358
  fail(
354
359
  "LIMIT_EXCEEDED",
355
- "overview range is too wide for the requested number of sites",
356
- { maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS },
360
+ `overview range is too wide: ${siteIds.length === 1 ? "one site" : `${siteIds.length} sites`} can read at most ${maximumBuckets} ${interval}s`,
361
+ { maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS, maximumBuckets },
357
362
  );
358
363
  }
359
364
  const totals = emptyTotals();
@@ -382,7 +387,13 @@ export const overview = query({
382
387
  await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
383
388
 
384
389
  for (const siteId of siteIds) {
385
- const buckets = await ctx.db
390
+ // Iterated rather than taken whole: within the row budget a busy range
391
+ // can still cross the bytes a query may read. Values are length-capped,
392
+ // so sixteen buckets stay far below the reserve, and running out fails
393
+ // with REPORT_INCOMPLETE, as the feature reports do, instead of a raw
394
+ // limit error.
395
+ const buckets: Doc<"aggregateBuckets">[] = [];
396
+ for await (const bucket of ctx.db
386
397
  .query("aggregateBuckets")
387
398
  .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
388
399
  range
@@ -390,8 +401,13 @@ export const overview = query({
390
401
  .eq("granularity", granularity)
391
402
  .gte("bucketStart", firstBucket)
392
403
  .lte("bucketStart", lastBucket),
393
- )
394
- .take(perSiteLimit + 1);
404
+ )) {
405
+ buckets.push(bucket);
406
+ if (buckets.length > perSiteLimit) break;
407
+ if (buckets.length % 16 === 0) {
408
+ await requireFeatureReadHeadroom(ctx, "overview aggregate buckets");
409
+ }
410
+ }
395
411
  failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
396
412
  for (const bucket of buckets) {
397
413
  if (
@@ -13,6 +13,7 @@ import {
13
13
  MAX_FUNNEL_STEPS,
14
14
  MAX_SITE_MAP_ROUTES_PER_DAY,
15
15
  REPORT_ROLLUP_SHARDS,
16
+ ROLLUP_FOLD_DEBOUNCE_MS,
16
17
  ROLLUP_FOLD_LEASE_MS,
17
18
  ROUTE_TRANSITION_SLOTS,
18
19
  SITE_MAP_OTHER_ROUTE,
@@ -36,8 +37,8 @@ import {
36
37
  * whole batches are lost (#52). Ingestion therefore never touches a rollup
37
38
  * row. It appends the batch's deltas to `reportRollupQueue` — an insert
38
39
  * conflicts with nothing — and one leased fold job per site merges the queue
39
- * into the rows a few hundred milliseconds later, so every rollup row has a
40
- * single writer.
40
+ * into the rows about a second later (see rollups.ts), so every rollup row
41
+ * has a single writer.
41
42
  */
42
43
 
43
44
  export type GoalRollupDelta = {
@@ -221,6 +222,10 @@ const queuedVitalValidator = v.object({
221
222
  export const reportRollupQueueFields = {
222
223
  siteId: v.id("sites"),
223
224
  createdAt: v.number(),
225
+ // Failed runs in a row with this batch at the head of the queue, and when
226
+ // it was set aside after too many (see rollups.ts).
227
+ foldFailures: v.optional(v.number()),
228
+ deadLetteredAt: v.optional(v.number()),
224
229
  goals: v.array(queuedGoalValidator),
225
230
  funnels: v.array(queuedFunnelValidator),
226
231
  funnelSteps: v.array(queuedFunnelStepValidator),
@@ -456,8 +461,9 @@ export function mergeQueuedRollups(
456
461
  /**
457
462
  * Appends a batch's rollup deltas to the site's queue and makes sure a fold
458
463
  * job will pick them up. The only document this reads is the site's fold
459
- * state, which the fold job rewrites about twice a minute under load, so
460
- * concurrent ingests almost never retry because of it.
464
+ * state, which no batch rewrites while a fold is pending and the fold job
465
+ * rewrites about twice a minute under load, so concurrent ingests almost
466
+ * never retry because of it.
461
467
  */
462
468
  export async function enqueueReportRollups(
463
469
  ctx: MutationCtx,
@@ -482,6 +488,7 @@ export async function ensureRollupFold(
482
488
  .query("rollupFoldStates")
483
489
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
484
490
  .unique();
491
+ // A held lease means a chain is running and will fold this batch.
485
492
  if (state && state.leaseUntil > now) return;
486
493
  const token = crypto.randomUUID();
487
494
  const leaseUntil = now + ROLLUP_FOLD_LEASE_MS;
@@ -490,7 +497,11 @@ export async function ensureRollupFold(
490
497
  } else {
491
498
  await ctx.db.insert("rollupFoldStates", { siteId, leaseUntil, token });
492
499
  }
493
- await ctx.scheduler.runAfter(0, internal.rollups.fold, { siteId, token });
500
+ // Debounced, so every batch of the next second joins this run.
501
+ await ctx.scheduler.runAfter(ROLLUP_FOLD_DEBOUNCE_MS, internal.rollups.fold, {
502
+ siteId,
503
+ token,
504
+ });
494
505
  }
495
506
 
496
507
  // ---------------------------------------------------------------------------
@@ -632,8 +643,9 @@ export async function flushReportRollups(
632
643
  for (const delta of deltas.routes.values()) {
633
644
  await upsertRouteRollup(ctx, delta, routeDayRowCounts);
634
645
  }
646
+ const vitalDayPageRows = new Map<string, number>();
635
647
  for (const delta of deltas.vitals.values()) {
636
- await upsertVitalRollup(ctx, delta);
648
+ await upsertVitalRollup(ctx, delta, vitalDayPageRows);
637
649
  }
638
650
  }
639
651
 
@@ -731,7 +743,11 @@ async function upsertRouteRollup(
731
743
  }
732
744
  }
733
745
 
734
- async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
746
+ async function upsertVitalRollup(
747
+ ctx: MutationCtx,
748
+ delta: VitalRollupDelta,
749
+ dayPageRows: Map<string, number>,
750
+ ) {
735
751
  const existing = await ctx.db
736
752
  .query("vitalRollups")
737
753
  .withIndex("by_key", (range) =>
@@ -748,21 +764,36 @@ async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
748
764
  delta.device === VITAL_ALL &&
749
765
  delta.page !== VITAL_OTHER_PAGES
750
766
  ) {
751
- // A new page row spends one bounded read to respect the daily page slots;
752
- // past the cap, the day's remaining pages fold into "(other)".
753
- const pageRows = await ctx.db
754
- .query("vitalRollups")
755
- .withIndex("by_key", (range) =>
756
- range
757
- .eq("siteId", delta.siteId)
758
- .eq("bucketStart", delta.bucketStart)
759
- .eq("device", VITAL_ALL),
760
- )
761
- .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
762
- if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
763
- await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
767
+ // New page rows spend one bounded read per day and fold run to respect
768
+ // the daily page slots, as routes do: rereading it for every new page let
769
+ // a fold of many distinct URLs pass the read limit and fail every retry.
770
+ // Past the cap, the day's remaining pages fold into "(other)".
771
+ const capRows = VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS;
772
+ const dayKey = `${delta.siteId}:${delta.bucketStart}`;
773
+ let pageRows = dayPageRows.get(dayKey);
774
+ if (pageRows === undefined) {
775
+ pageRows = (
776
+ await ctx.db
777
+ .query("vitalRollups")
778
+ .withIndex("by_key", (range) =>
779
+ range
780
+ .eq("siteId", delta.siteId)
781
+ .eq("bucketStart", delta.bucketStart)
782
+ .eq("device", VITAL_ALL),
783
+ )
784
+ .take(capRows + 1)
785
+ ).length;
786
+ dayPageRows.set(dayKey, pageRows);
787
+ }
788
+ if (pageRows > capRows) {
789
+ await upsertVitalRollup(
790
+ ctx,
791
+ { ...delta, page: VITAL_OTHER_PAGES },
792
+ dayPageRows,
793
+ );
764
794
  return;
765
795
  }
796
+ dayPageRows.set(dayKey, pageRows + 1);
766
797
  }
767
798
  const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
768
799
  for (const [metric, addition] of delta.metrics) {