@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.
- package/README.md +41 -18
- package/agent/integration.md +15 -7
- package/agent/manifest.json +9 -7
- package/agent/manifest.schema.json +8 -5
- package/dist/client/federation.d.ts +13 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +4 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +412 -10
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +55 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +68 -4
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/constants.d.ts +6 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +13 -1
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +1 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +13 -2
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +20 -8
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +144 -2
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/reports.d.ts +75 -1
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +275 -3
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +4 -4
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +12 -2
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +20 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +45 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +65 -3
- package/dist/component/schema.js +25 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/tracker/generated.d.ts +8 -8
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +8 -8
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +3 -1
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.js +4 -1
- package/dist/tracker/vitals.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -1
- package/docs/federation-setup.md +2 -0
- package/docs/federation.md +4 -0
- package/docs/upgrading.md +89 -8
- package/llms.txt +7 -1
- package/package.json +1 -1
- package/src/component/_generated/component.ts +55 -1
- package/src/component/constants.ts +13 -1
- package/src/component/coverage.ts +12 -1
- package/src/component/http.ts +22 -9
- package/src/component/ingest.ts +227 -0
- package/src/component/reports.ts +364 -2
- package/src/component/retention.ts +16 -1
- package/src/component/sanitize.ts +54 -0
- package/src/component/schema.ts +26 -0
package/src/component/reports.ts
CHANGED
|
@@ -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
|
-
({
|
|
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");
|
package/src/component/schema.ts
CHANGED
|
@@ -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"]),
|