@iann29/rastro 0.1.0-alpha.6 → 0.1.0-alpha.8

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 (67) hide show
  1. package/README.md +41 -18
  2. package/agent/integration.md +15 -7
  3. package/agent/manifest.json +9 -7
  4. package/agent/manifest.schema.json +8 -5
  5. package/dist/client/federation.d.ts +13 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +4 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +412 -10
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +55 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +68 -4
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/constants.d.ts +6 -1
  16. package/dist/component/constants.d.ts.map +1 -1
  17. package/dist/component/constants.js +13 -1
  18. package/dist/component/constants.js.map +1 -1
  19. package/dist/component/coverage.d.ts +1 -0
  20. package/dist/component/coverage.d.ts.map +1 -1
  21. package/dist/component/coverage.js +13 -2
  22. package/dist/component/coverage.js.map +1 -1
  23. package/dist/component/http.d.ts.map +1 -1
  24. package/dist/component/http.js +20 -8
  25. package/dist/component/http.js.map +1 -1
  26. package/dist/component/ingest.d.ts.map +1 -1
  27. package/dist/component/ingest.js +144 -2
  28. package/dist/component/ingest.js.map +1 -1
  29. package/dist/component/reports.d.ts +75 -1
  30. package/dist/component/reports.d.ts.map +1 -1
  31. package/dist/component/reports.js +275 -3
  32. package/dist/component/reports.js.map +1 -1
  33. package/dist/component/retention.d.ts +4 -4
  34. package/dist/component/retention.d.ts.map +1 -1
  35. package/dist/component/retention.js +12 -2
  36. package/dist/component/retention.js.map +1 -1
  37. package/dist/component/sanitize.d.ts +20 -0
  38. package/dist/component/sanitize.d.ts.map +1 -1
  39. package/dist/component/sanitize.js +45 -1
  40. package/dist/component/sanitize.js.map +1 -1
  41. package/dist/component/schema.d.ts +65 -3
  42. package/dist/component/schema.js +25 -0
  43. package/dist/component/schema.js.map +1 -1
  44. package/dist/tracker/generated.d.ts +8 -8
  45. package/dist/tracker/generated.d.ts.map +1 -1
  46. package/dist/tracker/generated.js +8 -8
  47. package/dist/tracker/generated.js.map +1 -1
  48. package/dist/tracker/tracker.js +3 -1
  49. package/dist/tracker/tracker.js.map +1 -1
  50. package/dist/tracker/vitals.js +4 -1
  51. package/dist/tracker/vitals.js.map +1 -1
  52. package/dist/tracker.min.js +1 -1
  53. package/dist/vitals.min.js +1 -1
  54. package/docs/federation-setup.md +2 -0
  55. package/docs/federation.md +4 -0
  56. package/docs/upgrading.md +89 -8
  57. package/llms.txt +7 -1
  58. package/package.json +1 -1
  59. package/src/component/_generated/component.ts +55 -1
  60. package/src/component/constants.ts +13 -1
  61. package/src/component/coverage.ts +12 -1
  62. package/src/component/http.ts +22 -9
  63. package/src/component/ingest.ts +227 -0
  64. package/src/component/reports.ts +364 -2
  65. package/src/component/retention.ts +16 -1
  66. package/src/component/sanitize.ts +54 -0
  67. package/src/component/schema.ts +26 -0
@@ -24,8 +24,12 @@ import {
24
24
  MAX_REPORT_BUCKETS,
25
25
  MAX_REPORT_RANGE_DAYS,
26
26
  MAX_REPORT_SITES,
27
+ MAX_SITE_MAP_ROUTES_PER_DAY,
28
+ MAX_SITE_MAP_ROWS,
27
29
  MAX_VITALS_REPORT_ROWS,
28
30
  REPORT_ROLLUP_SHARDS,
31
+ ROUTE_TRANSITION_SLOTS,
32
+ SITE_MAP_OTHER_ROUTE,
29
33
  VITALS_REPORT_PAGE_LIMIT,
30
34
  } from "./constants.js";
31
35
  import {
@@ -90,6 +94,8 @@ const liveSessionDocumentValidator = v.object({
90
94
  device: v.string(),
91
95
  pageviewCount: v.number(),
92
96
  eventCount: v.number(),
97
+ previousPath: v.optional(v.string()),
98
+ movedAt: v.optional(v.number()),
93
99
  });
94
100
  const conversionDocumentValidator = v.object({
95
101
  _id: v.id("conversions"),
@@ -439,10 +445,311 @@ export const liveVisitors = query({
439
445
  device: row.device,
440
446
  pageviewCount: row.pageviewCount,
441
447
  eventCount: row.eventCount,
448
+ ...(row.previousPath ? { previousPath: row.previousPath } : {}),
449
+ ...(row.movedAt !== undefined ? { movedAt: row.movedAt } : {}),
442
450
  }));
443
451
  },
444
452
  });
445
453
 
454
+ const siteMapRouteValidator = v.object({
455
+ route: v.string(),
456
+ pageviews: v.number(),
457
+ visitors: v.number(),
458
+ entries: v.number(),
459
+ exits: v.number(),
460
+ bounces: v.number(),
461
+ transitionsIn: v.number(),
462
+ transitionsOut: v.number(),
463
+ dwellMs: v.number(),
464
+ dwellSamples: v.number(),
465
+ daily: v.array(v.object({ bucketStart: v.number(), pageviews: v.number() })),
466
+ });
467
+ const siteMapTransitionValidator = v.object({
468
+ from: v.string(),
469
+ to: v.string(),
470
+ count: v.number(),
471
+ });
472
+ const siteMapCoverageDataset = "siteMapRollups";
473
+
474
+ /**
475
+ * Every route the sites served in the range and the transitions between
476
+ * them, read from the daily route rollups newest day first. Exits and
477
+ * bounces are derived rather than counted: a session enters once, leaves
478
+ * once, and every later pageview is a transition, so per route
479
+ * `exits = entries + transitionsIn - transitionsOut` and
480
+ * `bounces = entries - continued` hold without a session-end hook. The
481
+ * report never falls back to raw telemetry; days it could not cover are
482
+ * declared through `coverage` and `window`.
483
+ */
484
+ export const siteMap = query({
485
+ args: {
486
+ siteIds: v.array(v.id("sites")),
487
+ from: v.number(),
488
+ to: v.number(),
489
+ },
490
+ returns: v.object({
491
+ from: v.number(),
492
+ to: v.number(),
493
+ window: v.union(v.object({ from: v.number(), to: v.number() }), v.null()),
494
+ routes: v.array(siteMapRouteValidator),
495
+ transitions: v.array(siteMapTransitionValidator),
496
+ truncated: v.object({ routes: v.boolean(), transitions: v.boolean() }),
497
+ metadata: reportMetadataValidator.extend({
498
+ visitors: v.object({
499
+ basis: v.literal("estimatedUnique"),
500
+ exact: v.boolean(),
501
+ }),
502
+ limits: v.object({
503
+ routesPerDay: v.number(),
504
+ transitionSlots: v.number(),
505
+ rows: v.number(),
506
+ }),
507
+ }),
508
+ }),
509
+ handler: async (ctx, args) => {
510
+ const siteIds = validateSiteIds(args.siteIds);
511
+ validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
512
+ requireAlignedRange(args.from, args.to, DAY_MS, "site map");
513
+ const controls = await Promise.all(
514
+ siteIds.map((siteId) =>
515
+ ctx.db
516
+ .query("analyticsCoverage")
517
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
518
+ .unique(),
519
+ ),
520
+ );
521
+ const since = controls.every(
522
+ (control) => control?.siteMapSince !== undefined,
523
+ )
524
+ ? Math.max(...controls.map((control) => control!.siteMapSince!))
525
+ : null;
526
+ const retainedBefore = controls.reduce<number | null>((latest, control) => {
527
+ const before = control?.retained.find(
528
+ (item) => item.dataset === siteMapCoverageDataset,
529
+ )?.before;
530
+ return before === undefined ? latest : Math.max(latest ?? 0, before);
531
+ }, null);
532
+ const metadata = (state: "complete" | "partial" | "unavailable") => ({
533
+ basis: "dailyRollup" as const,
534
+ coverage: {
535
+ state,
536
+ generation: 1 as const,
537
+ availableFrom: since,
538
+ availableThrough: since === null ? null : args.to,
539
+ retainedBefore,
540
+ },
541
+ range: rangeSemantics(args.from, args.to),
542
+ visitors: { basis: "estimatedUnique" as const, exact: false },
543
+ limits: {
544
+ routesPerDay: MAX_SITE_MAP_ROUTES_PER_DAY,
545
+ transitionSlots: ROUTE_TRANSITION_SLOTS,
546
+ rows: MAX_SITE_MAP_ROWS,
547
+ },
548
+ });
549
+ if (
550
+ since === null ||
551
+ args.to < since ||
552
+ (retainedBefore !== null && args.to < retainedBefore)
553
+ ) {
554
+ return {
555
+ from: args.from,
556
+ to: args.to,
557
+ window: null,
558
+ routes: [],
559
+ transitions: [],
560
+ truncated: { routes: false, transitions: false },
561
+ metadata: metadata("unavailable"),
562
+ };
563
+ }
564
+ const readFrom = Math.max(
565
+ args.from,
566
+ Math.floor(since / DAY_MS) * DAY_MS,
567
+ retainedBefore ?? 0,
568
+ );
569
+ let budget = MAX_SITE_MAP_ROWS;
570
+ let completeFrom = readFrom;
571
+ const rows: Doc<"routeDailyRollups">[] = [];
572
+ for (const siteId of siteIds) {
573
+ const read = await readRouteRollups(
574
+ ctx,
575
+ siteId,
576
+ readFrom,
577
+ args.to,
578
+ budget,
579
+ );
580
+ budget -= read.rows.length;
581
+ completeFrom = Math.max(completeFrom, read.completeFrom);
582
+ rows.push(...read.rows);
583
+ }
584
+ const windowFrom = Math.max(
585
+ args.from,
586
+ completeFrom,
587
+ since,
588
+ retainedBefore ?? 0,
589
+ );
590
+
591
+ type RouteTotals = {
592
+ pageviews: number;
593
+ entries: number;
594
+ continued: number;
595
+ dwellMs: number;
596
+ dwellSamples: number;
597
+ transitionsIn: number;
598
+ transitionsOut: number;
599
+ sketch: VisitorSketch;
600
+ daily: Map<number, number>;
601
+ };
602
+ const routes = new Map<string, RouteTotals>();
603
+ const totalsFor = (route: string) => {
604
+ let current = routes.get(route);
605
+ if (!current) {
606
+ current = {
607
+ pageviews: 0,
608
+ entries: 0,
609
+ continued: 0,
610
+ dwellMs: 0,
611
+ dwellSamples: 0,
612
+ transitionsIn: 0,
613
+ transitionsOut: 0,
614
+ sketch: createVisitorSketch(),
615
+ daily: new Map<number, number>(),
616
+ };
617
+ routes.set(route, current);
618
+ }
619
+ return current;
620
+ };
621
+ const edges = new Map<
622
+ string,
623
+ { from: string; to: string; count: number }
624
+ >();
625
+ for (const row of rows) {
626
+ if (row.bucketStart < completeFrom) continue;
627
+ const current = totalsFor(row.route);
628
+ current.pageviews += row.pageviews;
629
+ current.entries += row.entries;
630
+ current.continued += row.continued;
631
+ current.dwellMs += row.dwellMs;
632
+ current.dwellSamples += row.dwellSamples;
633
+ current.sketch = unionVisitorSketches(current.sketch, row.visitorSketch);
634
+ current.daily.set(
635
+ row.bucketStart,
636
+ (current.daily.get(row.bucketStart) ?? 0) + row.pageviews,
637
+ );
638
+ for (const target of row.outgoing) {
639
+ const key = `${row.route}\u0000${target.route}`;
640
+ const edge = edges.get(key) ?? {
641
+ from: row.route,
642
+ to: target.route,
643
+ count: 0,
644
+ };
645
+ edge.count += target.count;
646
+ edges.set(key, edge);
647
+ current.transitionsOut += target.count;
648
+ }
649
+ }
650
+ // A target without a row of its own in this window — folded past the
651
+ // daily cap, or already retained — counts under "(other)".
652
+ const transitions = new Map<
653
+ string,
654
+ { from: string; to: string; count: number }
655
+ >();
656
+ for (const edge of edges.values()) {
657
+ const to = routes.has(edge.to) ? edge.to : SITE_MAP_OTHER_ROUTE;
658
+ const key = `${edge.from}\u0000${to}`;
659
+ const merged = transitions.get(key) ?? { from: edge.from, to, count: 0 };
660
+ merged.count += edge.count;
661
+ transitions.set(key, merged);
662
+ totalsFor(to).transitionsIn += edge.count;
663
+ }
664
+ const routeRows = [...routes.entries()]
665
+ .map(([route, totals]) => ({
666
+ route,
667
+ pageviews: totals.pageviews,
668
+ visitors: estimateVisitors(totals.sketch),
669
+ entries: totals.entries,
670
+ exits: Math.max(
671
+ 0,
672
+ totals.entries + totals.transitionsIn - totals.transitionsOut,
673
+ ),
674
+ bounces: Math.max(0, totals.entries - totals.continued),
675
+ transitionsIn: totals.transitionsIn,
676
+ transitionsOut: totals.transitionsOut,
677
+ dwellMs: totals.dwellMs,
678
+ dwellSamples: totals.dwellSamples,
679
+ daily: [...totals.daily.entries()]
680
+ .sort(([left], [right]) => left - right)
681
+ .map(([bucketStart, pageviews]) => ({ bucketStart, pageviews })),
682
+ }))
683
+ .sort(
684
+ (left, right) =>
685
+ right.pageviews - left.pageviews ||
686
+ left.route.localeCompare(right.route),
687
+ );
688
+ const transitionRows = [...transitions.values()].sort(
689
+ (left, right) =>
690
+ right.count - left.count ||
691
+ left.from.localeCompare(right.from) ||
692
+ left.to.localeCompare(right.to),
693
+ );
694
+ return {
695
+ from: args.from,
696
+ to: args.to,
697
+ window: completeFrom > args.to ? null : { from: windowFrom, to: args.to },
698
+ routes: routeRows,
699
+ transitions: transitionRows,
700
+ truncated: {
701
+ routes: routes.has(SITE_MAP_OTHER_ROUTE),
702
+ transitions: transitionRows.some(
703
+ (transition) =>
704
+ transition.from === SITE_MAP_OTHER_ROUTE ||
705
+ transition.to === SITE_MAP_OTHER_ROUTE,
706
+ ),
707
+ },
708
+ metadata: metadata(windowFrom > args.from ? "partial" : "complete"),
709
+ };
710
+ },
711
+ });
712
+
713
+ /**
714
+ * Reads a site's route rollups newest day first until the row budget or the
715
+ * transaction's read headroom runs out, then drops the day it could only
716
+ * read in part so every day it returns is complete.
717
+ */
718
+ async function readRouteRollups(
719
+ ctx: QueryCtx,
720
+ siteId: Id<"sites">,
721
+ from: number,
722
+ to: number,
723
+ budget: number,
724
+ ) {
725
+ const rows: Doc<"routeDailyRollups">[] = [];
726
+ let cutAt: number | null = null;
727
+ const query = ctx.db
728
+ .query("routeDailyRollups")
729
+ .withIndex("by_siteId_and_bucketStart", (range) =>
730
+ range
731
+ .eq("siteId", siteId)
732
+ .gte("bucketStart", from)
733
+ .lte("bucketStart", to),
734
+ )
735
+ .order("desc");
736
+ for await (const row of query) {
737
+ if (
738
+ rows.length >= budget ||
739
+ (rows.length % 256 === 255 && !(await hasFeatureReadHeadroom(ctx)))
740
+ ) {
741
+ cutAt = row.bucketStart;
742
+ break;
743
+ }
744
+ rows.push(row);
745
+ }
746
+ if (cutAt === null) return { rows, completeFrom: from };
747
+ while (rows.length > 0 && rows[rows.length - 1]!.bucketStart === cutAt) {
748
+ rows.pop();
749
+ }
750
+ return { rows, completeFrom: cutAt + DAY_MS };
751
+ }
752
+
446
753
  export const listSessions = query({
447
754
  args: {
448
755
  siteId: v.id("sites"),
@@ -504,7 +811,11 @@ export const listSessions = query({
504
811
  );
505
812
  }
506
813
  const publicRows = rows.map(
507
- ({ geoLookupAttemptedAt: _attemptedAt, ...row }) => row,
814
+ ({
815
+ geoLookupAttemptedAt: _attemptedAt,
816
+ lastPageviewAt: _lastPageviewAt,
817
+ ...row
818
+ }) => row,
508
819
  );
509
820
  return keysetPaginationResult(publicRows, pagination, (row) => ({
510
821
  timestamp: row.lastSeenAt,
@@ -1219,6 +1530,7 @@ const coverageDatasetValidator = v.union(
1219
1530
  v.literal("funnels"),
1220
1531
  v.literal("affiliates"),
1221
1532
  v.literal("vitals"),
1533
+ v.literal("siteMap"),
1222
1534
  );
1223
1535
 
1224
1536
  export const dataCoverage = query({
@@ -1250,6 +1562,7 @@ export const dataCoverage = query({
1250
1562
  "funnels",
1251
1563
  "affiliates",
1252
1564
  "vitals",
1565
+ "siteMap",
1253
1566
  ] as const;
1254
1567
  return {
1255
1568
  siteId: args.siteId,
@@ -1402,6 +1715,14 @@ function requireFeatureRollupBudget(
1402
1715
  }
1403
1716
  }
1404
1717
 
1718
+ async function hasFeatureReadHeadroom(ctx: QueryCtx) {
1719
+ const metrics = await ctx.meta.getTransactionMetrics();
1720
+ return (
1721
+ metrics.bytesRead.remaining >= FEATURE_REPORT_BYTE_RESERVE &&
1722
+ metrics.documentsRead.remaining >= FEATURE_REPORT_DOCUMENT_RESERVE
1723
+ );
1724
+ }
1725
+
1405
1726
  async function requireFeatureReadHeadroom(ctx: QueryCtx, dataset: string) {
1406
1727
  const metrics = await ctx.meta.getTransactionMetrics();
1407
1728
  if (
@@ -1545,6 +1866,9 @@ async function datasetCoverageForRange(
1545
1866
  from: number,
1546
1867
  to: number,
1547
1868
  ) {
1869
+ if (dataset === "siteMap") {
1870
+ return siteMapDatasetCoverage(ctx, siteId, from, to);
1871
+ }
1548
1872
  // Vitals have no legacy fallback source; the rollup either covers the range
1549
1873
  // or the coverage states say how much of it exists.
1550
1874
  if (dataset === "vitals") {
@@ -1584,6 +1908,43 @@ async function datasetCoverageForRange(
1584
1908
  )[0]!;
1585
1909
  }
1586
1910
 
1911
+ /**
1912
+ * The site map's coverage without reading its rows: it starts at the site's
1913
+ * `siteMapSince` and ends where retention cut it. The report itself may still
1914
+ * narrow the window when the range exceeds its row budget.
1915
+ */
1916
+ async function siteMapDatasetCoverage(
1917
+ ctx: QueryCtx,
1918
+ siteId: Id<"sites">,
1919
+ from: number,
1920
+ to: number,
1921
+ ) {
1922
+ const control = await ctx.db
1923
+ .query("analyticsCoverage")
1924
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1925
+ .unique();
1926
+ const since = control?.siteMapSince ?? null;
1927
+ const retainedBefore =
1928
+ control?.retained.find((item) => item.dataset === siteMapCoverageDataset)
1929
+ ?.before ?? null;
1930
+ const unavailable =
1931
+ since === null ||
1932
+ to < since ||
1933
+ (retainedBefore !== null && to < retainedBefore);
1934
+ const state = unavailable
1935
+ ? ("unavailable" as const)
1936
+ : from < Math.max(since, retainedBefore ?? 0)
1937
+ ? ("partial" as const)
1938
+ : ("complete" as const);
1939
+ return {
1940
+ state,
1941
+ generation: 1 as const,
1942
+ availableFrom: since,
1943
+ availableThrough: since === null ? null : to,
1944
+ retainedBefore,
1945
+ };
1946
+ }
1947
+
1587
1948
  type PublicCoverageDataset =
1588
1949
  | "overviewHour"
1589
1950
  | "overviewDay"
@@ -1593,7 +1954,8 @@ type PublicCoverageDataset =
1593
1954
  | "goals"
1594
1955
  | "funnels"
1595
1956
  | "affiliates"
1596
- | "vitals";
1957
+ | "vitals"
1958
+ | "siteMap";
1597
1959
 
1598
1960
  type CoverageSource =
1599
1961
  | "overviewHour"
@@ -30,6 +30,7 @@ export const cleanupKindValidator = v.union(
30
30
  v.literal("funnelRollups"),
31
31
  v.literal("affiliateRollups"),
32
32
  v.literal("vitalRollups"),
33
+ v.literal("siteMapRollups"),
33
34
  v.literal("attributions"),
34
35
  v.literal("liveSessions"),
35
36
  v.literal("ingestWindows"),
@@ -446,6 +447,7 @@ type CleanupKind =
446
447
  | "funnelRollups"
447
448
  | "affiliateRollups"
448
449
  | "vitalRollups"
450
+ | "siteMapRollups"
449
451
  | "attributions"
450
452
  | "liveSessions"
451
453
  | "ingestWindows"
@@ -465,6 +467,7 @@ const POLICY_CLEANUP_KINDS: CleanupKind[] = [
465
467
  "funnelRollups",
466
468
  "affiliateRollups",
467
469
  "vitalRollups",
470
+ "siteMapRollups",
468
471
  "attributions",
469
472
  "ingestWindows",
470
473
  "siteIngestWindows",
@@ -522,7 +525,8 @@ function safeCleanupBoundary(
522
525
  kind === "goalRollups" ||
523
526
  kind === "funnelRollups" ||
524
527
  kind === "affiliateRollups" ||
525
- kind === "vitalRollups"
528
+ kind === "vitalRollups" ||
529
+ kind === "siteMapRollups"
526
530
  ) {
527
531
  return safeBucketBoundary(requested, now, DAY_MS);
528
532
  }
@@ -633,6 +637,7 @@ function coverageDatasetsForKind(kind: CleanupKind): string[] {
633
637
  if (kind === "funnelRollups") return ["funnelRollups"];
634
638
  if (kind === "affiliateRollups") return ["affiliateRollups"];
635
639
  if (kind === "vitalRollups") return ["vitalRollups"];
640
+ if (kind === "siteMapRollups") return ["siteMapRollups"];
636
641
  return [];
637
642
  }
638
643
 
@@ -801,6 +806,16 @@ async function deletionsForCleanup(
801
806
  .take(limit)
802
807
  ).map((row) => () => ctx.db.delete("vitalRollups", row._id));
803
808
  }
809
+ if (kind === "siteMapRollups") {
810
+ return (
811
+ await ctx.db
812
+ .query("routeDailyRollups")
813
+ .withIndex("by_siteId_and_bucketStart", (q) =>
814
+ q.eq("siteId", siteId).lt("bucketStart", before),
815
+ )
816
+ .take(limit)
817
+ ).map((row) => () => ctx.db.delete("routeDailyRollups", row._id));
818
+ }
804
819
  if (kind === "attributions") {
805
820
  return (
806
821
  await ctx.db
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CLOCK_SKEW_TOLERANCE_MS,
2
3
  MAX_BATCH_BYTES,
3
4
  MAX_PROPERTIES,
4
5
  MAX_SITE_DOMAINS,
@@ -75,6 +76,37 @@ export function sanitizePath(value: string): string {
75
76
  return cleaned || "/";
76
77
  }
77
78
 
79
+ const ROUTE_ID = ":id";
80
+ const NUMERIC_SEGMENT = /^\d+$/;
81
+ const UUID_SEGMENT =
82
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
83
+ const HEX_SEGMENT = /^[0-9a-f]{16,}$/i;
84
+
85
+ /**
86
+ * Collapses the identifiers that unmistakably vary per record — numbers,
87
+ * UUIDs, and long hexadecimal tokens — into `:id`, so `/blog/42` and
88
+ * `/blog/43` roll up as one route in the site map. Slugs are pages of their
89
+ * own and stay as they are. A trailing slash is dropped because both
90
+ * spellings serve the same page. Only the map uses this; every other report
91
+ * keeps the sanitized path exactly as the tracker sent it.
92
+ */
93
+ export function normalizeRoute(path: string): string {
94
+ const trimmed =
95
+ path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
96
+ const segments = trimmed.split("/");
97
+ for (let index = 1; index < segments.length; index += 1) {
98
+ const segment = segments[index] ?? "";
99
+ if (
100
+ NUMERIC_SEGMENT.test(segment) ||
101
+ UUID_SEGMENT.test(segment) ||
102
+ HEX_SEGMENT.test(segment)
103
+ ) {
104
+ segments[index] = ROUTE_ID;
105
+ }
106
+ }
107
+ return segments.join("/") || "/";
108
+ }
109
+
78
110
  export function sanitizePublicUrl(value: string): string | undefined {
79
111
  const input = cleanString(value, 2048);
80
112
  if (!input) return undefined;
@@ -113,6 +145,28 @@ export function sanitizeProperties(
113
145
  return Object.keys(result).length > 0 ? result : undefined;
114
146
  }
115
147
 
148
+ /**
149
+ * Re-anchors a batch whose sender's clock disagrees with ours. `sentAt` is the
150
+ * sender's clock at flush time, so `now - sentAt` is the clock offset alone: an
151
+ * event queued long before the flush keeps its age, and nothing is inferred
152
+ * from the events themselves. Offsets inside the tolerance are latency or a
153
+ * slightly drifted clock and leave the batch untouched, so a well-set client's
154
+ * timestamps stay exactly as sent.
155
+ */
156
+ export function alignClock<T extends { timestamp: number }>(
157
+ events: T[],
158
+ sentAt: number | undefined,
159
+ now: number,
160
+ ): T[] {
161
+ if (sentAt === undefined || !Number.isFinite(sentAt)) return events;
162
+ const offset = now - sentAt;
163
+ if (Math.abs(offset) <= CLOCK_SKEW_TOLERANCE_MS) return events;
164
+ return events.map((event) => ({
165
+ ...event,
166
+ timestamp: event.timestamp + offset,
167
+ }));
168
+ }
169
+
116
170
  export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
117
171
  if (!Number.isFinite(event.timestamp))
118
172
  throw new Error("timestamp is invalid");
@@ -21,6 +21,7 @@ export default defineSchema({
21
21
  sessions: defineTable(
22
22
  sessionFieldsValidator.extend({
23
23
  geoLookupAttemptedAt: v.optional(v.number()),
24
+ lastPageviewAt: v.optional(v.number()),
24
25
  }),
25
26
  )
26
27
  .index("by_siteId_and_sessionId", ["siteId", "sessionId"])
@@ -53,6 +54,8 @@ export default defineSchema({
53
54
  lastHeartbeatEventId: v.optional(v.string()),
54
55
  recentHeartbeatEventIds: v.optional(v.array(v.string())),
55
56
  eventCount: v.number(),
57
+ previousPath: v.optional(v.string()),
58
+ movedAt: v.optional(v.number()),
56
59
  })
57
60
  .index("by_siteId_and_sessionId", ["siteId", "sessionId"])
58
61
  .index("by_siteId_and_expiresAt", ["siteId", "expiresAt"])
@@ -103,6 +106,28 @@ export default defineSchema({
103
106
  "bucketStart",
104
107
  ]),
105
108
 
109
+ routeDailyRollups: defineTable({
110
+ siteId: v.id("sites"),
111
+ bucketStart: v.number(),
112
+ shard: v.number(),
113
+ generation: v.literal(1),
114
+ route: v.string(),
115
+ pageviews: v.number(),
116
+ entries: v.number(),
117
+ continued: v.number(),
118
+ dwellMs: v.number(),
119
+ dwellSamples: v.number(),
120
+ visitorSketch: visitorSketchValidator,
121
+ outgoing: v.array(v.object({ route: v.string(), count: v.number() })),
122
+ })
123
+ .index("by_siteId_and_route_and_bucketStart_and_shard", [
124
+ "siteId",
125
+ "route",
126
+ "bucketStart",
127
+ "shard",
128
+ ])
129
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
130
+
106
131
  goalDailyRollups: defineTable({
107
132
  siteId: v.id("sites"),
108
133
  goalId: v.id("goals"),
@@ -206,6 +231,7 @@ export default defineSchema({
206
231
  siteId: v.id("sites"),
207
232
  generation: v.literal(1),
208
233
  rollupCompleteFrom: v.number(),
234
+ siteMapSince: v.optional(v.number()),
209
235
  retained: v.array(v.object({ dataset: v.string(), before: v.number() })),
210
236
  updatedAt: v.number(),
211
237
  }).index("by_siteId", ["siteId"]),