@iann29/rastro 0.3.0 → 0.5.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 (128) hide show
  1. package/README.md +199 -50
  2. package/agent/integration.md +26 -10
  3. package/agent/manifest.json +10 -5
  4. package/agent/manifest.schema.json +14 -5
  5. package/dist/client/federation.d.ts +28 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +12 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +262 -9
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +119 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +6 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +72 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +1 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/constants.d.ts +9 -2
  22. package/dist/component/constants.d.ts.map +1 -1
  23. package/dist/component/constants.js +31 -3
  24. package/dist/component/constants.js.map +1 -1
  25. package/dist/component/convex.config.d.ts +1 -0
  26. package/dist/component/convex.config.js +3 -0
  27. package/dist/component/convex.config.js.map +1 -1
  28. package/dist/component/coverage.d.ts +1 -0
  29. package/dist/component/coverage.d.ts.map +1 -1
  30. package/dist/component/coverage.js +6 -1
  31. package/dist/component/coverage.js.map +1 -1
  32. package/dist/component/eventStore.d.ts +2 -0
  33. package/dist/component/eventStore.d.ts.map +1 -1
  34. package/dist/component/geo.d.ts +1 -0
  35. package/dist/component/geo.d.ts.map +1 -1
  36. package/dist/component/geo.js +1 -1
  37. package/dist/component/geo.js.map +1 -1
  38. package/dist/component/http.d.ts.map +1 -1
  39. package/dist/component/http.js +65 -2
  40. package/dist/component/http.js.map +1 -1
  41. package/dist/component/identity.d.ts +12 -0
  42. package/dist/component/identity.d.ts.map +1 -1
  43. package/dist/component/identity.js +30 -2
  44. package/dist/component/identity.js.map +1 -1
  45. package/dist/component/ingest.d.ts +36 -0
  46. package/dist/component/ingest.d.ts.map +1 -1
  47. package/dist/component/ingest.js +111 -20
  48. package/dist/component/ingest.js.map +1 -1
  49. package/dist/component/live.d.ts.map +1 -1
  50. package/dist/component/live.js +34 -4
  51. package/dist/component/live.js.map +1 -1
  52. package/dist/component/origin.d.ts +70 -0
  53. package/dist/component/origin.d.ts.map +1 -0
  54. package/dist/component/origin.js +230 -0
  55. package/dist/component/origin.js.map +1 -0
  56. package/dist/component/platforms.d.ts +33 -0
  57. package/dist/component/platforms.d.ts.map +1 -0
  58. package/dist/component/platforms.js +328 -0
  59. package/dist/component/platforms.js.map +1 -0
  60. package/dist/component/reports.d.ts +27 -72
  61. package/dist/component/reports.d.ts.map +1 -1
  62. package/dist/component/reports.js +72 -16
  63. package/dist/component/reports.js.map +1 -1
  64. package/dist/component/rollupStore.d.ts +7 -4
  65. package/dist/component/rollupStore.d.ts.map +1 -1
  66. package/dist/component/rollupStore.js +37 -17
  67. package/dist/component/rollupStore.js.map +1 -1
  68. package/dist/component/rollups.d.ts +27 -8
  69. package/dist/component/rollups.d.ts.map +1 -1
  70. package/dist/component/rollups.js +110 -32
  71. package/dist/component/rollups.js.map +1 -1
  72. package/dist/component/sanitize.d.ts +5 -0
  73. package/dist/component/sanitize.d.ts.map +1 -1
  74. package/dist/component/sanitize.js +33 -1
  75. package/dist/component/sanitize.js.map +1 -1
  76. package/dist/component/schema.d.ts +68 -9
  77. package/dist/component/schema.js +21 -3
  78. package/dist/component/schema.js.map +1 -1
  79. package/dist/component/trackedLinks.d.ts +91 -0
  80. package/dist/component/trackedLinks.d.ts.map +1 -0
  81. package/dist/component/trackedLinks.js +314 -0
  82. package/dist/component/trackedLinks.js.map +1 -0
  83. package/dist/component/useragent.d.ts +6 -0
  84. package/dist/component/useragent.d.ts.map +1 -1
  85. package/dist/component/useragent.js +9 -0
  86. package/dist/component/useragent.js.map +1 -1
  87. package/dist/component/validators.d.ts +51 -11
  88. package/dist/component/validators.d.ts.map +1 -1
  89. package/dist/component/validators.js +35 -1
  90. package/dist/component/validators.js.map +1 -1
  91. package/dist/tracker/generated.d.ts +11 -11
  92. package/dist/tracker/generated.d.ts.map +1 -1
  93. package/dist/tracker/generated.js +11 -11
  94. package/dist/tracker/generated.js.map +1 -1
  95. package/dist/tracker/tracker.d.ts +1 -0
  96. package/dist/tracker/tracker.d.ts.map +1 -1
  97. package/dist/tracker/tracker.js +104 -47
  98. package/dist/tracker/tracker.js.map +1 -1
  99. package/dist/tracker/vitals.d.ts.map +1 -1
  100. package/dist/tracker/vitals.js +22 -4
  101. package/dist/tracker/vitals.js.map +1 -1
  102. package/dist/tracker.min.js +1 -1
  103. package/dist/vitals.min.js +1 -1
  104. package/docs/upgrading.md +234 -45
  105. package/llms.txt +9 -4
  106. package/package.json +2 -2
  107. package/src/component/_generated/api.ts +6 -0
  108. package/src/component/_generated/component.ts +153 -1
  109. package/src/component/_generated/server.ts +1 -0
  110. package/src/component/constants.ts +31 -3
  111. package/src/component/convex.config.ts +3 -0
  112. package/src/component/coverage.ts +7 -1
  113. package/src/component/geo.ts +1 -1
  114. package/src/component/http.ts +71 -2
  115. package/src/component/identity.ts +41 -2
  116. package/src/component/ingest.ts +146 -33
  117. package/src/component/live.ts +51 -4
  118. package/src/component/origin.ts +273 -0
  119. package/src/component/platforms.ts +359 -0
  120. package/src/component/reports.ts +98 -18
  121. package/src/component/rollupStore.ts +51 -20
  122. package/src/component/rollups.ts +141 -32
  123. package/src/component/sanitize.ts +39 -1
  124. package/src/component/schema.ts +24 -3
  125. package/src/component/trackedLinks.ts +384 -0
  126. package/src/component/useragent.ts +11 -0
  127. package/src/component/validators.ts +71 -0
  128. package/src/tracker/generated.ts +11 -11
@@ -55,6 +55,8 @@ import {
55
55
  type RawJourneyEvent,
56
56
  } from "./eventStore.js";
57
57
  import { isPlainRecord } from "./guards.js";
58
+ import { classifyOrigin } from "./origin.js";
59
+ import { externalReferrer } from "./sanitize.js";
58
60
  import {
59
61
  affiliateFieldsValidator,
60
62
  eventFieldsValidator,
@@ -220,6 +222,11 @@ export const overview = query({
220
222
  topMediums: v.array(topItemValidator),
221
223
  // Destination hosts of outbound clicks, ranked by clicks.
222
224
  topOutbound: v.array(topItemValidator),
225
+ // The session's origin (origin.ts), ranked like sources: platform ids,
226
+ // channel ids, and the evidence that decided them.
227
+ topPlatforms: v.array(topItemValidator),
228
+ topChannels: v.array(topItemValidator),
229
+ topEvidence: v.array(topItemValidator),
223
230
  metadata: v.object({
224
231
  visitors: v.object({
225
232
  basis: v.union(
@@ -242,6 +249,9 @@ export const overview = query({
242
249
  topGoals: v.literal("goalCompletionVolume"),
243
250
  topMediums: breakdownBasisValidator,
244
251
  topOutbound: v.literal("eventVolume"),
252
+ topPlatforms: breakdownBasisValidator,
253
+ topChannels: breakdownBasisValidator,
254
+ topEvidence: breakdownBasisValidator,
245
255
  }),
246
256
  // Since when every site in the request records the medium and outbound
247
257
  // slots; null while one of them has not stamped it. A range that starts
@@ -346,14 +356,19 @@ export const overview = query({
346
356
  const bucketCount = localDays
347
357
  ? bucketStarts.length
348
358
  : Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
349
- const perSiteLimit =
350
- bucketCount *
351
- (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
359
+ const shards =
360
+ interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS;
361
+ const perSiteLimit = bucketCount * shards;
352
362
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
363
+ // The row budget, not MAX_REPORT_RANGE_DAYS, bounds the overview: 101
364
+ // days for one site and 10 for ten; 24 hours for two, 5 for ten.
365
+ const maximumBuckets = Math.floor(
366
+ MAX_OVERVIEW_AGGREGATE_ROWS / (shards * siteIds.length),
367
+ );
353
368
  fail(
354
369
  "LIMIT_EXCEEDED",
355
- "overview range is too wide for the requested number of sites",
356
- { maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS },
370
+ `overview range is too wide: ${siteIds.length === 1 ? "one site" : `${siteIds.length} sites`} can read at most ${maximumBuckets} ${interval}s`,
371
+ { maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS, maximumBuckets },
357
372
  );
358
373
  }
359
374
  const totals = emptyTotals();
@@ -382,7 +397,13 @@ export const overview = query({
382
397
  await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
383
398
 
384
399
  for (const siteId of siteIds) {
385
- const buckets = await ctx.db
400
+ // Iterated rather than taken whole: within the row budget a busy range
401
+ // can still cross the bytes a query may read. Values are length-capped,
402
+ // so sixteen buckets stay far below the reserve, and running out fails
403
+ // with REPORT_INCOMPLETE, as the feature reports do, instead of a raw
404
+ // limit error.
405
+ const buckets: Doc<"aggregateBuckets">[] = [];
406
+ for await (const bucket of ctx.db
386
407
  .query("aggregateBuckets")
387
408
  .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
388
409
  range
@@ -390,8 +411,13 @@ export const overview = query({
390
411
  .eq("granularity", granularity)
391
412
  .gte("bucketStart", firstBucket)
392
413
  .lte("bucketStart", lastBucket),
393
- )
394
- .take(perSiteLimit + 1);
414
+ )) {
415
+ buckets.push(bucket);
416
+ if (buckets.length > perSiteLimit) break;
417
+ if (buckets.length % 16 === 0) {
418
+ await requireFeatureReadHeadroom(ctx, "overview aggregate buckets");
419
+ }
420
+ }
395
421
  failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
396
422
  for (const bucket of buckets) {
397
423
  if (
@@ -520,6 +546,9 @@ export const overview = query({
520
546
  topGoals: top("goal", "eventVolume"),
521
547
  topMediums: top("medium", sessionBasis),
522
548
  topOutbound: top("outbound", "eventVolume"),
549
+ topPlatforms: top("platform", sessionBasis),
550
+ topChannels: top("channel", sessionBasis),
551
+ topEvidence: top("evidence", sessionBasis),
523
552
  metadata: {
524
553
  visitors: {
525
554
  basis: visitorBasis,
@@ -539,6 +568,9 @@ export const overview = query({
539
568
  topGoals: "goalCompletionVolume" as const,
540
569
  topMediums: sessionBasis,
541
570
  topOutbound: "eventVolume" as const,
571
+ topPlatforms: sessionBasis,
572
+ topChannels: sessionBasis,
573
+ topEvidence: sessionBasis,
542
574
  },
543
575
  dimensionsSince: controls.every(
544
576
  (control) => control?.dimensionsSince !== undefined,
@@ -932,6 +964,27 @@ async function readRouteRollups(
932
964
  return { rows, completeFrom: cutAt + DAY_MS };
933
965
  }
934
966
 
967
+ type SessionRow = Omit<
968
+ Doc<"sessions">,
969
+ "geoLookupAttemptedAt" | "lastPageviewAt"
970
+ >;
971
+
972
+ /**
973
+ * A session recorded before origins existed, classified on read from what it
974
+ * kept (utm fields, referrer, affiliate) and marked `legacy`: it carries no
975
+ * click IDs or in-app signal, and it never enters the aggregates.
976
+ */
977
+ function withOrigin(session: SessionRow, domains: string[]): SessionRow {
978
+ if (session.platform !== undefined) return session;
979
+ const origin = classifyOrigin({
980
+ utmSource: session.utmSource,
981
+ utmMedium: session.utmMedium,
982
+ referrer: externalReferrer(session.referrer, domains),
983
+ affiliate: session.affiliateSlug !== undefined,
984
+ });
985
+ return { ...session, ...origin, evidence: "legacy" };
986
+ }
987
+
935
988
  /** Metadata for a selected session, independent of list pagination. */
936
989
  export const getSession = query({
937
990
  args: { siteId: v.id("sites"), sessionId: v.string() },
@@ -949,7 +1002,8 @@ export const getSession = query({
949
1002
  lastPageviewAt: _pageview,
950
1003
  ...result
951
1004
  } = session;
952
- return result;
1005
+ const site = await ctx.db.get("sites", args.siteId);
1006
+ return withOrigin(result, site?.domains ?? []);
953
1007
  },
954
1008
  });
955
1009
 
@@ -1088,12 +1142,13 @@ export const listSessions = query({
1088
1142
  .take(take)),
1089
1143
  );
1090
1144
  }
1145
+ const site = await ctx.db.get("sites", args.siteId);
1091
1146
  const publicRows = rows.map(
1092
1147
  ({
1093
1148
  geoLookupAttemptedAt: _attemptedAt,
1094
1149
  lastPageviewAt: _lastPageviewAt,
1095
1150
  ...row
1096
- }) => row,
1151
+ }) => withOrigin(row, site?.domains ?? []),
1097
1152
  );
1098
1153
  return keysetPaginationResult(publicRows, pagination, (row) => ({
1099
1154
  timestamp: row.lastSeenAt,
@@ -1810,6 +1865,7 @@ const coverageDatasetValidator = v.union(
1810
1865
  v.literal("affiliates"),
1811
1866
  v.literal("vitals"),
1812
1867
  v.literal("siteMap"),
1868
+ v.literal("origin"),
1813
1869
  );
1814
1870
 
1815
1871
  export const dataCoverage = query({
@@ -1843,6 +1899,7 @@ export const dataCoverage = query({
1843
1899
  "affiliates",
1844
1900
  "vitals",
1845
1901
  "siteMap",
1902
+ "origin",
1846
1903
  ] as const;
1847
1904
  return {
1848
1905
  siteId: args.siteId,
@@ -2147,7 +2204,26 @@ async function datasetCoverageForRange(
2147
2204
  to: number,
2148
2205
  ) {
2149
2206
  if (dataset === "siteMap") {
2150
- return siteMapDatasetCoverage(ctx, siteId, from, to);
2207
+ return stampedDatasetCoverage(
2208
+ ctx,
2209
+ siteId,
2210
+ from,
2211
+ to,
2212
+ "siteMapSince",
2213
+ siteMapCoverageDataset,
2214
+ );
2215
+ }
2216
+ // Origins exist in sessions and aggregate slots from the site's stamp, and
2217
+ // the overview reads them from the daily aggregates retention trims.
2218
+ if (dataset === "origin") {
2219
+ return stampedDatasetCoverage(
2220
+ ctx,
2221
+ siteId,
2222
+ from,
2223
+ to,
2224
+ "originSince",
2225
+ "overviewDay",
2226
+ );
2151
2227
  }
2152
2228
  // Vitals have no legacy fallback source; the rollup either covers the range
2153
2229
  // or the coverage states say how much of it exists.
@@ -2189,23 +2265,26 @@ async function datasetCoverageForRange(
2189
2265
  }
2190
2266
 
2191
2267
  /**
2192
- * The site map's coverage without reading its rows: it starts at the site's
2193
- * `siteMapSince` and ends where retention cut it. The report itself may still
2194
- * narrow the window when the range exceeds its row budget.
2268
+ * A stamped dataset's coverage without reading its rows: it starts at the
2269
+ * site's stamp (`siteMapSince`, `originSince`) and ends where retention cut
2270
+ * it. The site map report may still narrow the window when the range exceeds
2271
+ * its row budget.
2195
2272
  */
2196
- async function siteMapDatasetCoverage(
2273
+ async function stampedDatasetCoverage(
2197
2274
  ctx: QueryCtx,
2198
2275
  siteId: Id<"sites">,
2199
2276
  from: number,
2200
2277
  to: number,
2278
+ stamp: "siteMapSince" | "originSince",
2279
+ retainedDataset: string,
2201
2280
  ) {
2202
2281
  const control = await ctx.db
2203
2282
  .query("analyticsCoverage")
2204
2283
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
2205
2284
  .unique();
2206
- const since = control?.siteMapSince ?? null;
2285
+ const since = control?.[stamp] ?? null;
2207
2286
  const retainedBefore =
2208
- control?.retained.find((item) => item.dataset === siteMapCoverageDataset)
2287
+ control?.retained.find((item) => item.dataset === retainedDataset)
2209
2288
  ?.before ?? null;
2210
2289
  const unavailable =
2211
2290
  since === null ||
@@ -2236,7 +2315,8 @@ type PublicCoverageDataset =
2236
2315
  | "funnels"
2237
2316
  | "affiliates"
2238
2317
  | "vitals"
2239
- | "siteMap";
2318
+ | "siteMap"
2319
+ | "origin";
2240
2320
 
2241
2321
  type CoverageSource =
2242
2322
  | "overviewHour"
@@ -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) {
@@ -1,9 +1,14 @@
1
- import { v } from "convex/values";
1
+ import { v, type Infer } from "convex/values";
2
2
  import { internal } from "./_generated/api.js";
3
+ import type { Id } from "./_generated/dataModel.js";
4
+ import type { MutationCtx } from "./_generated/server.js";
3
5
  import { internalMutation } from "./_generated/server.js";
4
6
  import {
7
+ ROLLUP_DEAD_LETTER_RETENTION_MS,
5
8
  ROLLUP_FOLD_BATCH_DOCS,
9
+ ROLLUP_FOLD_DEBOUNCE_MS,
6
10
  ROLLUP_FOLD_LEASE_MS,
11
+ ROLLUP_FOLD_MAX_FAILURES,
7
12
  ROLLUP_FOLD_MAX_ROWS,
8
13
  } from "./constants.js";
9
14
  import {
@@ -13,24 +18,65 @@ import {
13
18
  mergeQueuedRollups,
14
19
  } from "./rollupStore.js";
15
20
 
21
+ const foldArgs = { siteId: v.id("sites"), token: v.string() };
22
+ const foldResult = v.object({
23
+ folded: v.number(),
24
+ remaining: v.boolean(),
25
+ stale: v.boolean(),
26
+ });
27
+ type FoldResult = Infer<typeof foldResult>;
28
+
29
+ // A failed run's reads still count against the transaction that catches it,
30
+ // so a run stops short of the platform's read limits (16 MiB, 32,000
31
+ // documents, 4,096 queries) and leaves room to record the failure.
32
+ const RUN_READ_LIMITS = {
33
+ bytesRead: 15 * 1024 * 1024,
34
+ documentsRead: 31_000,
35
+ databaseQueries: 4_000,
36
+ };
37
+
16
38
  /**
17
39
  * Folds a site's queued report rollup deltas into the rollup rows. Exactly
18
- * one chain runs per site: ingestion starts one when no lease is held, every
19
- * run carries the token of the lease it belongs to, and a run whose token is
20
- * no longer current exits without touching anything. A run that finds more
21
- * queued documents than it could take continues at once; a run that drains
22
- * the queue releases the lease. A batch that lands while a run is draining
23
- * is never stranded: the run read the queue range the batch inserted into
24
- * and the batch read the lease the run released, so one of the two retries
25
- * and sees the other's work.
40
+ * one chain runs per site: a batch that finds the lease free takes it and
41
+ * schedules the first run a debounce away (see ensureRollupFold), every run
42
+ * carries the token of that lease, and a run whose token is no longer
43
+ * current exits without touching anything. Batches that land while the
44
+ * lease is held only append to the queue; they never write the lease or
45
+ * schedule anything.
46
+ *
47
+ * A run folds the batches created before it began and, if it folded any,
48
+ * schedules the next run a debounce later (at once while a backlog remains),
49
+ * so a steady stream costs one run per second and batches landing during a
50
+ * run never conflict with it. Only a run that finds nothing left releases
51
+ * the lease, and that run reads the whole queue range: a batch landing
52
+ * concurrently either commits first, and the run retries and folds it, or
53
+ * reads the released lease and starts a new chain.
54
+ *
55
+ * A run executes as a nested transaction, so one that throws is rolled back
56
+ * whole and its batches stay queued; the oldest is charged the failure and
57
+ * the next run retries it alone. After ROLLUP_FOLD_MAX_FAILURES runs in a
58
+ * row it is set aside as a dead letter and the queue moves on.
26
59
  */
27
60
  export const fold = internalMutation({
28
- args: { siteId: v.id("sites"), token: v.string() },
29
- returns: v.object({
30
- folded: v.number(),
31
- remaining: v.boolean(),
32
- stale: v.boolean(),
33
- }),
61
+ args: foldArgs,
62
+ returns: foldResult,
63
+ handler: async (ctx, args) => {
64
+ try {
65
+ const result: FoldResult = await ctx.runMutation(
66
+ internal.rollups.foldRun,
67
+ args,
68
+ { transactionLimits: RUN_READ_LIMITS },
69
+ );
70
+ return result;
71
+ } catch (error) {
72
+ return await setAsideFailedRun(ctx, args, error);
73
+ }
74
+ },
75
+ });
76
+
77
+ export const foldRun = internalMutation({
78
+ args: foldArgs,
79
+ returns: foldResult,
34
80
  handler: async (ctx, args) => {
35
81
  const state = await ctx.db
36
82
  .query("rollupFoldStates")
@@ -42,20 +88,31 @@ export const fold = internalMutation({
42
88
  const now = Date.now();
43
89
  const queued = await ctx.db
44
90
  .query("reportRollupQueue")
45
- .withIndex("by_siteId_and_createdAt", (range) =>
46
- range.eq("siteId", args.siteId),
91
+ .withIndex("by_siteId_and_deadLetteredAt_and_createdAt", (range) =>
92
+ range
93
+ .eq("siteId", args.siteId)
94
+ .eq("deadLetteredAt", undefined)
95
+ .lte("createdAt", now),
47
96
  )
48
- .order("asc")
49
97
  .take(ROLLUP_FOLD_BATCH_DOCS + 1);
50
98
  if (queued.length === 0) {
51
- if (state.leaseUntil > now) {
99
+ if (await oldestQueued(ctx, args.siteId)) {
100
+ await ctx.scheduler.runAfter(
101
+ ROLLUP_FOLD_DEBOUNCE_MS,
102
+ internal.rollups.fold,
103
+ args,
104
+ );
105
+ } else if (state.leaseUntil > now) {
52
106
  await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil: now });
53
107
  }
54
108
  return { folded: 0, remaining: false, stale: false };
55
109
  }
110
+ // A batch that already failed a run is retried alone, so the next
111
+ // failure is charged to the batch that causes it.
112
+ const batchDocs = queued[0].foldFailures ? 1 : ROLLUP_FOLD_BATCH_DOCS;
56
113
  const deltas = createReportRollupDeltas();
57
114
  let taken = 0;
58
- for (const document of queued.slice(0, ROLLUP_FOLD_BATCH_DOCS)) {
115
+ for (const document of queued.slice(0, batchDocs)) {
59
116
  mergeQueuedRollups(deltas, document);
60
117
  taken += 1;
61
118
  if (countReportRollupRows(deltas) >= ROLLUP_FOLD_MAX_ROWS) break;
@@ -64,19 +121,71 @@ export const fold = internalMutation({
64
121
  for (const document of queued.slice(0, taken)) {
65
122
  await ctx.db.delete("reportRollupQueue", document._id);
66
123
  }
67
- const remaining = queued.length > taken;
68
- if (remaining) {
69
- // Renew only when the lease is halfway gone, so the state document is
70
- // rewritten about twice a minute under load rather than on every run.
71
- if (state.leaseUntil - now < ROLLUP_FOLD_LEASE_MS / 2) {
72
- await ctx.db.patch("rollupFoldStates", state._id, {
73
- leaseUntil: now + ROLLUP_FOLD_LEASE_MS,
74
- });
75
- }
76
- await ctx.scheduler.runAfter(0, internal.rollups.fold, args);
77
- } else {
78
- await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil: now });
124
+ // Renew only when the lease is halfway gone, so the state document is
125
+ // rewritten about twice a minute while batches keep arriving.
126
+ if (state.leaseUntil - now < ROLLUP_FOLD_LEASE_MS / 2) {
127
+ await ctx.db.patch("rollupFoldStates", state._id, {
128
+ leaseUntil: now + ROLLUP_FOLD_LEASE_MS,
129
+ });
79
130
  }
131
+ const remaining = queued.length > taken;
132
+ await ctx.scheduler.runAfter(
133
+ remaining ? 0 : ROLLUP_FOLD_DEBOUNCE_MS,
134
+ internal.rollups.fold,
135
+ args,
136
+ );
80
137
  return { folded: taken, remaining, stale: false };
81
138
  },
82
139
  });
140
+
141
+ /** The oldest batch still waiting to fold; dead letters are skipped. */
142
+ function oldestQueued(ctx: MutationCtx, siteId: Id<"sites">) {
143
+ return ctx.db
144
+ .query("reportRollupQueue")
145
+ .withIndex("by_siteId_and_deadLetteredAt_and_createdAt", (range) =>
146
+ range.eq("siteId", siteId).eq("deadLetteredAt", undefined),
147
+ )
148
+ .first();
149
+ }
150
+
151
+ async function setAsideFailedRun(
152
+ ctx: MutationCtx,
153
+ args: { siteId: Id<"sites">; token: string },
154
+ error: unknown,
155
+ ): Promise<FoldResult> {
156
+ const state = await ctx.db
157
+ .query("rollupFoldStates")
158
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
159
+ .unique();
160
+ const head = await oldestQueued(ctx, args.siteId);
161
+ if (!head || state?.token !== args.token) throw error;
162
+ const now = Date.now();
163
+ const foldFailures = (head.foldFailures ?? 0) + 1;
164
+ if (foldFailures < ROLLUP_FOLD_MAX_FAILURES) {
165
+ await ctx.db.patch("reportRollupQueue", head._id, { foldFailures });
166
+ } else {
167
+ console.error(
168
+ `rollup fold set aside queued batch ${head._id} of site ${args.siteId} after ${foldFailures} failed runs`,
169
+ error,
170
+ );
171
+ await ctx.db.patch("reportRollupQueue", head._id, {
172
+ foldFailures,
173
+ deadLetteredAt: now,
174
+ });
175
+ // Dead letters stay for inspection; each new one retires expired ones.
176
+ const expired = await ctx.db
177
+ .query("reportRollupQueue")
178
+ .withIndex("by_siteId_and_deadLetteredAt_and_createdAt", (range) =>
179
+ range
180
+ .eq("siteId", args.siteId)
181
+ .gte("deadLetteredAt", 0)
182
+ .lt("deadLetteredAt", now - ROLLUP_DEAD_LETTER_RETENTION_MS),
183
+ )
184
+ .take(ROLLUP_FOLD_BATCH_DOCS);
185
+ for (const document of expired) {
186
+ await ctx.db.delete("reportRollupQueue", document._id);
187
+ }
188
+ }
189
+ await ctx.scheduler.runAfter(0, internal.rollups.fold, args);
190
+ return { folded: 0, remaining: true, stale: false };
191
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  TrackerEvent,
11
11
  } from "./validators.js";
12
12
  import { isValidVitalValue, isVitalMetric } from "./vitals.js";
13
+ import { isPlatform, knownClickIds } from "./origin.js";
13
14
 
14
15
  const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
15
16
  const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
@@ -121,6 +122,27 @@ export function sanitizePublicUrl(value: string): string | undefined {
121
122
  }
122
123
  }
123
124
 
125
+ const ANDROID_PACKAGE = /^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+$/;
126
+
127
+ /**
128
+ * A referrer as sanitizePublicUrl keeps it, or the Android app that opened
129
+ * the link: `android-app://<package>/…` keeps the package alone.
130
+ */
131
+ function sanitizeReferrer(value: string): string | undefined {
132
+ const input = cleanString(value, 2048);
133
+ if (!input.toLowerCase().startsWith("android-app://")) {
134
+ return sanitizePublicUrl(input);
135
+ }
136
+ try {
137
+ const pkg = new URL(input).hostname.toLowerCase();
138
+ return pkg.length <= 120 && ANDROID_PACKAGE.test(pkg)
139
+ ? `android-app://${pkg}`
140
+ : undefined;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
124
146
  /**
125
147
  * The host of a public URL the way the outbound dimension names it: lowercase,
126
148
  * without port, path, or query. A relative path has no host.
@@ -229,7 +251,7 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
229
251
  referrer:
230
252
  event.referrer === undefined
231
253
  ? undefined
232
- : sanitizePublicUrl(event.referrer),
254
+ : sanitizeReferrer(event.referrer),
233
255
  timestamp: Math.trunc(event.timestamp),
234
256
  sequence: event.sequence,
235
257
  target:
@@ -246,6 +268,7 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
246
268
  event.type === "vital" && event.value !== undefined
247
269
  ? Math.round(event.value)
248
270
  : undefined,
271
+ clid: event.clid === undefined ? undefined : knownClickIds(event.clid),
249
272
  };
250
273
  }
251
274
 
@@ -353,6 +376,8 @@ export function sanitizeContext(
353
376
  context?.visitorKey && SAFE_OPAQUE_ID.test(context.visitorKey)
354
377
  ? cleanString(context.visitorKey, 128) || undefined
355
378
  : undefined,
379
+ inApp:
380
+ context?.inApp && isPlatform(context.inApp) ? context.inApp : undefined,
356
381
  };
357
382
  }
358
383
 
@@ -393,6 +418,19 @@ export function sourceFromReferrer(referrer: string | undefined): string {
393
418
  }
394
419
  }
395
420
 
421
+ /**
422
+ * The referrer unless it is internal: a relative path or one of the site's
423
+ * own domains never names where a session came from.
424
+ */
425
+ export function externalReferrer(
426
+ referrer: string | undefined,
427
+ domains: string[],
428
+ ): string | undefined {
429
+ if (!referrer || referrer.startsWith("/") || originAllowed(referrer, domains))
430
+ return undefined;
431
+ return referrer;
432
+ }
433
+
396
434
  export function stableHash(value: string): number {
397
435
  let hash = 2_166_136_261;
398
436
  for (let index = 0; index < value.length; index += 1) {