@iann29/rastro 0.1.0-alpha.4 → 0.1.0-alpha.6
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 +203 -56
- package/agent/integration.md +17 -15
- package/agent/manifest.json +6 -4
- package/agent/manifest.schema.json +8 -5
- package/dist/client/federation.d.ts +34 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +17 -3
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +486 -31
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +209 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +8 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +82 -8
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/affiliates.d.ts.map +1 -1
- package/dist/component/affiliates.js +6 -2
- package/dist/component/affiliates.js.map +1 -1
- package/dist/component/cardinality.d.ts.map +1 -1
- package/dist/component/cardinality.js.map +1 -1
- package/dist/component/constants.d.ts +6 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +12 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/diagnostics.d.ts.map +1 -1
- package/dist/component/diagnostics.js.map +1 -1
- package/dist/component/eventStore.d.ts +3 -1
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +13 -10
- package/dist/component/eventStore.js.map +1 -1
- package/dist/component/funnels.d.ts.map +1 -1
- package/dist/component/funnels.js +5 -3
- package/dist/component/funnels.js.map +1 -1
- package/dist/component/geo.d.ts.map +1 -1
- package/dist/component/geo.js +3 -2
- package/dist/component/geo.js.map +1 -1
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +8 -5
- package/dist/component/goals.js.map +1 -1
- package/dist/component/guards.d.ts.map +1 -1
- package/dist/component/guards.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +71 -37
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +13 -0
- package/dist/component/identity.d.ts.map +1 -0
- package/dist/component/identity.js +58 -0
- package/dist/component/identity.js.map +1 -0
- package/dist/component/ingest.d.ts +3 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +168 -29
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +1 -3
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +70 -4
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +232 -59
- 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 +54 -22
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +42 -11
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +83 -10
- package/dist/component/schema.js +34 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +6 -6
- package/dist/component/sites.js.map +1 -1
- package/dist/component/useragent.d.ts +9 -0
- package/dist/component/useragent.d.ts.map +1 -0
- package/dist/component/useragent.js +152 -0
- package/dist/component/useragent.js.map +1 -0
- package/dist/component/validators.d.ts +29 -18
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +8 -2
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +19 -0
- package/dist/component/visitors.d.ts.map +1 -0
- package/dist/component/visitors.js +86 -0
- package/dist/component/visitors.js.map +1 -0
- package/dist/component/vitals.d.ts +41 -0
- package/dist/component/vitals.d.ts.map +1 -0
- package/dist/component/vitals.js +115 -0
- package/dist/component/vitals.js.map +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/tracker/generated.d.ts +8 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +8 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +23 -11
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts +10 -0
- package/dist/tracker/vitals.d.ts.map +1 -0
- package/dist/tracker/vitals.js +137 -0
- package/dist/tracker/vitals.js.map +1 -0
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -0
- package/docs/benchmarks/2026-08-20-realistic.md +71 -71
- package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
- package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
- package/docs/federation.md +1 -1
- package/package.json +27 -9
- package/scripts/benchmark-ingest.mjs +101 -48
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +87 -5
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +8 -7
- package/src/component/constants.ts +12 -0
- package/src/component/diagnostics.ts +3 -2
- package/src/component/eventStore.ts +53 -46
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +16 -14
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +126 -78
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +349 -130
- package/src/component/live.ts +5 -4
- package/src/component/reports.ts +572 -221
- package/src/component/retention.ts +221 -98
- package/src/component/sanitize.ts +77 -27
- package/src/component/schema.ts +46 -5
- package/src/component/sites.ts +22 -11
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +19 -7
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
package/src/component/reports.ts
CHANGED
|
@@ -24,7 +24,9 @@ import {
|
|
|
24
24
|
MAX_REPORT_BUCKETS,
|
|
25
25
|
MAX_REPORT_RANGE_DAYS,
|
|
26
26
|
MAX_REPORT_SITES,
|
|
27
|
+
MAX_VITALS_REPORT_ROWS,
|
|
27
28
|
REPORT_ROLLUP_SHARDS,
|
|
29
|
+
VITALS_REPORT_PAGE_LIMIT,
|
|
28
30
|
} from "./constants.js";
|
|
29
31
|
import {
|
|
30
32
|
createVisitorSketch,
|
|
@@ -48,7 +50,17 @@ import {
|
|
|
48
50
|
funnelFieldsValidator,
|
|
49
51
|
goalFieldsValidator,
|
|
50
52
|
sessionFieldsValidator,
|
|
53
|
+
vitalMetricValidator,
|
|
51
54
|
} from "./validators.js";
|
|
55
|
+
import { resolveVisitorIdentities } from "./visitors.js";
|
|
56
|
+
import {
|
|
57
|
+
mergeVitalHistograms,
|
|
58
|
+
VITAL_ALL,
|
|
59
|
+
VITAL_METRICS,
|
|
60
|
+
vitalHistogramPercentile,
|
|
61
|
+
vitalRatingCounts,
|
|
62
|
+
type VitalMetric,
|
|
63
|
+
} from "./vitals.js";
|
|
52
64
|
|
|
53
65
|
const sessionDocumentValidator = sessionFieldsValidator.extend({
|
|
54
66
|
_id: v.id("sessions"),
|
|
@@ -109,7 +121,9 @@ const metricTotalsValidator = v.object({
|
|
|
109
121
|
conversions: v.number(),
|
|
110
122
|
revenueCents: v.number(),
|
|
111
123
|
});
|
|
112
|
-
const timePointValidator = metricTotalsValidator.extend({
|
|
124
|
+
const timePointValidator = metricTotalsValidator.extend({
|
|
125
|
+
timestamp: v.number(),
|
|
126
|
+
});
|
|
113
127
|
const topItemValidator = v.object({
|
|
114
128
|
value: v.string(),
|
|
115
129
|
count: v.number(),
|
|
@@ -155,6 +169,7 @@ export const overview = query({
|
|
|
155
169
|
totals: metricTotalsValidator,
|
|
156
170
|
timeSeries: v.array(timePointValidator),
|
|
157
171
|
topSources: v.array(topItemValidator),
|
|
172
|
+
topCampaigns: v.array(topItemValidator),
|
|
158
173
|
topPages: v.array(topItemValidator),
|
|
159
174
|
topCountries: v.array(topItemValidator),
|
|
160
175
|
topDevices: v.array(topItemValidator),
|
|
@@ -174,6 +189,7 @@ export const overview = query({
|
|
|
174
189
|
}),
|
|
175
190
|
breakdowns: v.object({
|
|
176
191
|
topSources: v.literal("eventVolume"),
|
|
192
|
+
topCampaigns: v.literal("eventVolume"),
|
|
177
193
|
topPages: v.literal("eventVolume"),
|
|
178
194
|
topCountries: v.literal("eventVolume"),
|
|
179
195
|
topDevices: v.literal("eventVolume"),
|
|
@@ -187,7 +203,7 @@ export const overview = query({
|
|
|
187
203
|
sessionReplay: v.literal("unsupported"),
|
|
188
204
|
errorInsights: v.literal("unsupported"),
|
|
189
205
|
outboundLinks: v.literal("eventVolume"),
|
|
190
|
-
botDetection: v.literal("
|
|
206
|
+
botDetection: v.literal("userAgent"),
|
|
191
207
|
}),
|
|
192
208
|
}),
|
|
193
209
|
}),
|
|
@@ -196,7 +212,8 @@ export const overview = query({
|
|
|
196
212
|
const currencies = new Set<string>();
|
|
197
213
|
for (const siteId of siteIds) {
|
|
198
214
|
const site = await ctx.db.get("sites", siteId);
|
|
199
|
-
if (!site)
|
|
215
|
+
if (!site)
|
|
216
|
+
fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
|
|
200
217
|
currencies.add(site.currency ?? "USD");
|
|
201
218
|
}
|
|
202
219
|
if (currencies.size !== 1) {
|
|
@@ -205,7 +222,11 @@ export const overview = query({
|
|
|
205
222
|
});
|
|
206
223
|
}
|
|
207
224
|
const currency = [...currencies][0];
|
|
208
|
-
const { from, to } = validateRange(
|
|
225
|
+
const { from, to } = validateRange(
|
|
226
|
+
args.from,
|
|
227
|
+
args.to,
|
|
228
|
+
MAX_REPORT_RANGE_DAYS,
|
|
229
|
+
);
|
|
209
230
|
const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
|
|
210
231
|
if (interval === "hour" && to - from > DAY_MS) {
|
|
211
232
|
fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
|
|
@@ -215,7 +236,8 @@ export const overview = query({
|
|
|
215
236
|
const firstBucket = Math.floor(from / intervalMs) * intervalMs;
|
|
216
237
|
const lastBucket = Math.floor(to / intervalMs) * intervalMs;
|
|
217
238
|
const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
|
|
218
|
-
const perSiteLimit =
|
|
239
|
+
const perSiteLimit =
|
|
240
|
+
bucketCount *
|
|
219
241
|
(interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
220
242
|
if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
|
|
221
243
|
fail(
|
|
@@ -230,10 +252,14 @@ export const overview = query({
|
|
|
230
252
|
let mergedVisitorSketch = visitorSketch;
|
|
231
253
|
const pointVisitorSketches = new Map<number, VisitorSketch>();
|
|
232
254
|
let visitorsComplete = true;
|
|
233
|
-
const dimensions = new Map<
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
255
|
+
const dimensions = new Map<
|
|
256
|
+
string,
|
|
257
|
+
{ value: string; count: number; revenueCents: number }
|
|
258
|
+
>();
|
|
259
|
+
const overviewDataset =
|
|
260
|
+
interval === "hour"
|
|
261
|
+
? ("overviewHour" as const)
|
|
262
|
+
: ("overviewDay" as const);
|
|
237
263
|
|
|
238
264
|
await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
|
|
239
265
|
|
|
@@ -288,7 +314,10 @@ export const overview = query({
|
|
|
288
314
|
[...dimensions.entries()]
|
|
289
315
|
.filter(([key]) => key.startsWith(`${type}:`))
|
|
290
316
|
.map(([, value]) => value)
|
|
291
|
-
.sort(
|
|
317
|
+
.sort(
|
|
318
|
+
(left, right) =>
|
|
319
|
+
right.count - left.count || left.value.localeCompare(right.value),
|
|
320
|
+
)
|
|
292
321
|
.slice(0, 10);
|
|
293
322
|
if (visitorsComplete) {
|
|
294
323
|
totals.visitors = estimateVisitors(mergedVisitorSketch);
|
|
@@ -314,6 +343,7 @@ export const overview = query({
|
|
|
314
343
|
.sort(([left], [right]) => left - right)
|
|
315
344
|
.map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
|
|
316
345
|
topSources: top("source"),
|
|
346
|
+
topCampaigns: top("campaign"),
|
|
317
347
|
topPages: top("page"),
|
|
318
348
|
topCountries: top("country"),
|
|
319
349
|
topDevices: top("device"),
|
|
@@ -330,6 +360,7 @@ export const overview = query({
|
|
|
330
360
|
},
|
|
331
361
|
breakdowns: {
|
|
332
362
|
topSources: "eventVolume" as const,
|
|
363
|
+
topCampaigns: "eventVolume" as const,
|
|
333
364
|
topPages: "eventVolume" as const,
|
|
334
365
|
topCountries: "eventVolume" as const,
|
|
335
366
|
topDevices: "eventVolume" as const,
|
|
@@ -343,7 +374,7 @@ export const overview = query({
|
|
|
343
374
|
sessionReplay: "unsupported" as const,
|
|
344
375
|
errorInsights: "unsupported" as const,
|
|
345
376
|
outboundLinks: "eventVolume" as const,
|
|
346
|
-
botDetection: "
|
|
377
|
+
botDetection: "userAgent" as const,
|
|
347
378
|
},
|
|
348
379
|
},
|
|
349
380
|
};
|
|
@@ -360,8 +391,15 @@ export const liveVisitors = query({
|
|
|
360
391
|
handler: async (ctx, args) => {
|
|
361
392
|
const siteIds = validateSiteIds(args.siteIds);
|
|
362
393
|
const limit = args.limit ?? 100;
|
|
363
|
-
if (
|
|
364
|
-
|
|
394
|
+
if (
|
|
395
|
+
!Number.isSafeInteger(limit) ||
|
|
396
|
+
limit < 1 ||
|
|
397
|
+
limit > MAX_LIVE_VISITORS
|
|
398
|
+
) {
|
|
399
|
+
fail(
|
|
400
|
+
"INVALID_ARGUMENT",
|
|
401
|
+
`limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`,
|
|
402
|
+
);
|
|
365
403
|
}
|
|
366
404
|
if (!Number.isFinite(args.now) || args.now < 0) {
|
|
367
405
|
fail("INVALID_ARGUMENT", "now must be a positive timestamp");
|
|
@@ -588,23 +626,37 @@ export const visitorJourney = query({
|
|
|
588
626
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
589
627
|
await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
|
|
590
628
|
const limit = args.limit ?? 200;
|
|
591
|
-
if (
|
|
592
|
-
|
|
629
|
+
if (
|
|
630
|
+
!Number.isSafeInteger(limit) ||
|
|
631
|
+
limit < 1 ||
|
|
632
|
+
limit > MAX_JOURNEY_EVENTS
|
|
633
|
+
) {
|
|
634
|
+
fail(
|
|
635
|
+
"INVALID_ARGUMENT",
|
|
636
|
+
`limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`,
|
|
637
|
+
);
|
|
593
638
|
}
|
|
594
639
|
const rows: RawJourneyEvent[] = [];
|
|
595
640
|
for (const siteId of siteIds) {
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
: args.to;
|
|
599
|
-
rows.push(...await loadVisitorJourneyEvents(ctx, {
|
|
641
|
+
for (const visitorId of await resolveVisitorIdentities(
|
|
642
|
+
ctx,
|
|
600
643
|
siteId,
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
644
|
+
args.visitorId,
|
|
645
|
+
)) {
|
|
646
|
+
const cutoff =
|
|
647
|
+
rows.length >= limit ? rows[limit - 1]!.timestamp : args.to;
|
|
648
|
+
rows.push(
|
|
649
|
+
...(await loadVisitorJourneyEvents(ctx, {
|
|
650
|
+
siteId,
|
|
651
|
+
visitorId,
|
|
652
|
+
from: args.from,
|
|
653
|
+
to: cutoff,
|
|
654
|
+
resultLimit: limit,
|
|
655
|
+
})),
|
|
656
|
+
);
|
|
657
|
+
rows.sort(compareJourneyEvents);
|
|
658
|
+
if (rows.length > limit) rows.length = limit;
|
|
659
|
+
}
|
|
608
660
|
}
|
|
609
661
|
return hydrateEventContexts(ctx, rows);
|
|
610
662
|
},
|
|
@@ -632,7 +684,10 @@ export const goalsReport = query({
|
|
|
632
684
|
.take(MAX_GOALS_PER_SITE + 1);
|
|
633
685
|
failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
|
|
634
686
|
const goals = goalRows;
|
|
635
|
-
const totals = new Map<
|
|
687
|
+
const totals = new Map<
|
|
688
|
+
string,
|
|
689
|
+
{ completions: number; valueCents: number }
|
|
690
|
+
>();
|
|
636
691
|
const useRollups = await dailyRollupsAvailable(
|
|
637
692
|
ctx,
|
|
638
693
|
args.siteId,
|
|
@@ -689,12 +744,18 @@ export const goalsReport = query({
|
|
|
689
744
|
})),
|
|
690
745
|
truncated: false,
|
|
691
746
|
metadata: {
|
|
692
|
-
basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
|
|
693
|
-
coverage: await coverageForRange(
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
747
|
+
basis: rollups ? ("dailyRollup" as const) : ("boundedLegacy" as const),
|
|
748
|
+
coverage: await coverageForRange(
|
|
749
|
+
ctx,
|
|
750
|
+
[args.siteId],
|
|
751
|
+
args.from,
|
|
752
|
+
args.to,
|
|
753
|
+
{
|
|
754
|
+
datasets: [rollups ? "goalRollups" : "goalCompletions"],
|
|
755
|
+
complete: true,
|
|
756
|
+
generation: rollups ? 1 : null,
|
|
757
|
+
},
|
|
758
|
+
),
|
|
698
759
|
range: rangeSemantics(args.from, args.to),
|
|
699
760
|
},
|
|
700
761
|
};
|
|
@@ -705,7 +766,12 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
|
|
|
705
766
|
_id: v.id("funnels"),
|
|
706
767
|
_creationTime: v.number(),
|
|
707
768
|
stepsReport: v.array(
|
|
708
|
-
v.object({
|
|
769
|
+
v.object({
|
|
770
|
+
step: v.number(),
|
|
771
|
+
name: v.string(),
|
|
772
|
+
entrants: v.number(),
|
|
773
|
+
completions: v.number(),
|
|
774
|
+
}),
|
|
709
775
|
),
|
|
710
776
|
truncated: v.boolean(),
|
|
711
777
|
metadata: reportMetadataValidator,
|
|
@@ -795,7 +861,9 @@ export const funnelsReport = query({
|
|
|
795
861
|
stepsReport,
|
|
796
862
|
truncated: false,
|
|
797
863
|
metadata: {
|
|
798
|
-
basis: rollups
|
|
864
|
+
basis: rollups
|
|
865
|
+
? ("dailyRollup" as const)
|
|
866
|
+
: ("boundedLegacy" as const),
|
|
799
867
|
coverage: reportCoverage,
|
|
800
868
|
range: rangeSemantics(args.from, args.to),
|
|
801
869
|
},
|
|
@@ -835,7 +903,11 @@ export const affiliatesReport = query({
|
|
|
835
903
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
836
904
|
.order("desc")
|
|
837
905
|
.take(MAX_AFFILIATES_PER_SITE + 1);
|
|
838
|
-
failIfIncomplete(
|
|
906
|
+
failIfIncomplete(
|
|
907
|
+
affiliateRows,
|
|
908
|
+
MAX_AFFILIATES_PER_SITE,
|
|
909
|
+
"affiliate definitions",
|
|
910
|
+
);
|
|
839
911
|
const affiliates = affiliateRows;
|
|
840
912
|
const useRollups = await dailyRollupsAvailable(
|
|
841
913
|
ctx,
|
|
@@ -853,16 +925,22 @@ export const affiliatesReport = query({
|
|
|
853
925
|
args.to,
|
|
854
926
|
)
|
|
855
927
|
: null;
|
|
856
|
-
const visits = new Map<
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
928
|
+
const visits = new Map<
|
|
929
|
+
string,
|
|
930
|
+
{
|
|
931
|
+
visits: number;
|
|
932
|
+
visitors: Set<string>;
|
|
933
|
+
sketch: VisitorSketch;
|
|
934
|
+
}
|
|
935
|
+
>();
|
|
936
|
+
const totals = new Map<
|
|
937
|
+
string,
|
|
938
|
+
{
|
|
939
|
+
conversions: number;
|
|
940
|
+
revenueCents: number;
|
|
941
|
+
commissionCents: number;
|
|
942
|
+
}
|
|
943
|
+
>();
|
|
866
944
|
if (rollups) {
|
|
867
945
|
for (const row of rollups) {
|
|
868
946
|
const key = String(row.affiliateId);
|
|
@@ -953,22 +1031,30 @@ export const affiliatesReport = query({
|
|
|
953
1031
|
visits: traffic?.visits ?? 0,
|
|
954
1032
|
uniqueVisitors: rollups
|
|
955
1033
|
? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
|
|
956
|
-
: traffic?.visitors.size ?? 0,
|
|
1034
|
+
: (traffic?.visitors.size ?? 0),
|
|
957
1035
|
};
|
|
958
1036
|
}),
|
|
959
1037
|
truncated: false,
|
|
960
1038
|
metadata: {
|
|
961
|
-
basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
|
|
962
|
-
coverage: await coverageForRange(
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1039
|
+
basis: rollups ? ("dailyRollup" as const) : ("boundedLegacy" as const),
|
|
1040
|
+
coverage: await coverageForRange(
|
|
1041
|
+
ctx,
|
|
1042
|
+
[args.siteId],
|
|
1043
|
+
args.from,
|
|
1044
|
+
args.to,
|
|
1045
|
+
{
|
|
1046
|
+
datasets: rollups
|
|
1047
|
+
? ["affiliateRollups"]
|
|
1048
|
+
: ["affiliateStats", "sessions"],
|
|
1049
|
+
complete: true,
|
|
1050
|
+
generation: rollups ? 1 : null,
|
|
1051
|
+
},
|
|
1052
|
+
),
|
|
969
1053
|
range: rangeSemantics(args.from, args.to),
|
|
970
1054
|
uniqueVisitors: {
|
|
971
|
-
basis: rollups
|
|
1055
|
+
basis: rollups
|
|
1056
|
+
? ("estimatedUnique" as const)
|
|
1057
|
+
: ("exactLegacy" as const),
|
|
972
1058
|
exact: !rollups,
|
|
973
1059
|
},
|
|
974
1060
|
},
|
|
@@ -976,6 +1062,153 @@ export const affiliatesReport = query({
|
|
|
976
1062
|
},
|
|
977
1063
|
});
|
|
978
1064
|
|
|
1065
|
+
const vitalStatsValidator = v.object({
|
|
1066
|
+
count: v.number(),
|
|
1067
|
+
p75: v.number(),
|
|
1068
|
+
average: v.number(),
|
|
1069
|
+
good: v.number(),
|
|
1070
|
+
needsImprovement: v.number(),
|
|
1071
|
+
poor: v.number(),
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
export const vitalsReport = query({
|
|
1075
|
+
args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
|
|
1076
|
+
returns: v.object({
|
|
1077
|
+
metrics: v.array(
|
|
1078
|
+
vitalStatsValidator.extend({
|
|
1079
|
+
metric: vitalMetricValidator,
|
|
1080
|
+
devices: v.array(vitalStatsValidator.extend({ device: v.string() })),
|
|
1081
|
+
pages: v.array(vitalStatsValidator.extend({ page: v.string() })),
|
|
1082
|
+
timeSeries: v.array(
|
|
1083
|
+
v.object({
|
|
1084
|
+
timestamp: v.number(),
|
|
1085
|
+
count: v.number(),
|
|
1086
|
+
p75: v.number(),
|
|
1087
|
+
}),
|
|
1088
|
+
),
|
|
1089
|
+
}),
|
|
1090
|
+
),
|
|
1091
|
+
truncated: v.boolean(),
|
|
1092
|
+
metadata: reportMetadataValidator,
|
|
1093
|
+
}),
|
|
1094
|
+
handler: async (ctx, args) => {
|
|
1095
|
+
validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
|
|
1096
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "vitals report");
|
|
1097
|
+
await assertSourceAvailable(
|
|
1098
|
+
ctx,
|
|
1099
|
+
[args.siteId],
|
|
1100
|
+
["vitalRollups"],
|
|
1101
|
+
args.from,
|
|
1102
|
+
);
|
|
1103
|
+
const rows = await ctx.db
|
|
1104
|
+
.query("vitalRollups")
|
|
1105
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
1106
|
+
range
|
|
1107
|
+
.eq("siteId", args.siteId)
|
|
1108
|
+
.gte("bucketStart", args.from)
|
|
1109
|
+
.lte("bucketStart", args.to),
|
|
1110
|
+
)
|
|
1111
|
+
.take(MAX_VITALS_REPORT_ROWS + 1);
|
|
1112
|
+
failIfIncomplete(rows, MAX_VITALS_REPORT_ROWS, "vital rollups");
|
|
1113
|
+
|
|
1114
|
+
type Accumulated = { count: number; sum: number; histogram: number[] };
|
|
1115
|
+
const fold = <Key>(
|
|
1116
|
+
groups: Map<VitalMetric, Map<Key, Accumulated>>,
|
|
1117
|
+
metric: VitalMetric,
|
|
1118
|
+
key: Key,
|
|
1119
|
+
entry: { count: number; sum: number; histogram: number[] },
|
|
1120
|
+
) => {
|
|
1121
|
+
const group = groups.get(metric) ?? new Map<Key, Accumulated>();
|
|
1122
|
+
const current = group.get(key) ?? { count: 0, sum: 0, histogram: [] };
|
|
1123
|
+
current.count += entry.count;
|
|
1124
|
+
current.sum += entry.sum;
|
|
1125
|
+
current.histogram = mergeVitalHistograms(
|
|
1126
|
+
current.histogram,
|
|
1127
|
+
entry.histogram,
|
|
1128
|
+
);
|
|
1129
|
+
group.set(key, current);
|
|
1130
|
+
groups.set(metric, group);
|
|
1131
|
+
};
|
|
1132
|
+
const byDevice = new Map<VitalMetric, Map<string, Accumulated>>();
|
|
1133
|
+
const byPage = new Map<VitalMetric, Map<string, Accumulated>>();
|
|
1134
|
+
const byDay = new Map<VitalMetric, Map<number, Accumulated>>();
|
|
1135
|
+
for (const row of rows) {
|
|
1136
|
+
for (const entry of row.metrics) {
|
|
1137
|
+
if (row.page === VITAL_ALL) {
|
|
1138
|
+
// A device row carries the complete signal once, so overall totals
|
|
1139
|
+
// and the time series derive from this family alone.
|
|
1140
|
+
fold(byDevice, entry.metric, row.device, entry);
|
|
1141
|
+
fold(byDay, entry.metric, row.bucketStart, entry);
|
|
1142
|
+
} else {
|
|
1143
|
+
fold(byPage, entry.metric, row.page, entry);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const stats = (metric: VitalMetric, accumulated: Accumulated) => ({
|
|
1148
|
+
count: accumulated.count,
|
|
1149
|
+
p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
|
|
1150
|
+
average:
|
|
1151
|
+
accumulated.count > 0
|
|
1152
|
+
? Math.round(accumulated.sum / accumulated.count)
|
|
1153
|
+
: 0,
|
|
1154
|
+
...vitalRatingCounts(accumulated.histogram, metric),
|
|
1155
|
+
});
|
|
1156
|
+
const metrics = VITAL_METRICS.map((metric) => {
|
|
1157
|
+
const overall: Accumulated = { count: 0, sum: 0, histogram: [] };
|
|
1158
|
+
for (const accumulated of byDevice.get(metric)?.values() ?? []) {
|
|
1159
|
+
overall.count += accumulated.count;
|
|
1160
|
+
overall.sum += accumulated.sum;
|
|
1161
|
+
overall.histogram = mergeVitalHistograms(
|
|
1162
|
+
overall.histogram,
|
|
1163
|
+
accumulated.histogram,
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
return {
|
|
1167
|
+
metric,
|
|
1168
|
+
...stats(metric, overall),
|
|
1169
|
+
devices: [...(byDevice.get(metric) ?? [])]
|
|
1170
|
+
.map(([device, accumulated]) => ({
|
|
1171
|
+
device,
|
|
1172
|
+
...stats(metric, accumulated),
|
|
1173
|
+
}))
|
|
1174
|
+
.sort((left, right) => right.count - left.count),
|
|
1175
|
+
pages: [...(byPage.get(metric) ?? [])]
|
|
1176
|
+
.map(([page, accumulated]) => ({
|
|
1177
|
+
page,
|
|
1178
|
+
...stats(metric, accumulated),
|
|
1179
|
+
}))
|
|
1180
|
+
.sort(
|
|
1181
|
+
(left, right) =>
|
|
1182
|
+
right.count - left.count || left.page.localeCompare(right.page),
|
|
1183
|
+
)
|
|
1184
|
+
.slice(0, VITALS_REPORT_PAGE_LIMIT),
|
|
1185
|
+
timeSeries: [...(byDay.get(metric) ?? [])]
|
|
1186
|
+
.sort(([left], [right]) => left - right)
|
|
1187
|
+
.map(([timestamp, accumulated]) => ({
|
|
1188
|
+
timestamp,
|
|
1189
|
+
count: accumulated.count,
|
|
1190
|
+
p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
|
|
1191
|
+
})),
|
|
1192
|
+
};
|
|
1193
|
+
});
|
|
1194
|
+
return {
|
|
1195
|
+
metrics,
|
|
1196
|
+
truncated: false,
|
|
1197
|
+
metadata: {
|
|
1198
|
+
basis: "dailyRollup" as const,
|
|
1199
|
+
coverage: await coverageForRange(
|
|
1200
|
+
ctx,
|
|
1201
|
+
[args.siteId],
|
|
1202
|
+
args.from,
|
|
1203
|
+
args.to,
|
|
1204
|
+
{ datasets: ["vitalRollups"], complete: true },
|
|
1205
|
+
),
|
|
1206
|
+
range: rangeSemantics(args.from, args.to),
|
|
1207
|
+
},
|
|
1208
|
+
};
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
|
|
979
1212
|
const coverageDatasetValidator = v.union(
|
|
980
1213
|
v.literal("overviewHour"),
|
|
981
1214
|
v.literal("overviewDay"),
|
|
@@ -985,6 +1218,7 @@ const coverageDatasetValidator = v.union(
|
|
|
985
1218
|
v.literal("goals"),
|
|
986
1219
|
v.literal("funnels"),
|
|
987
1220
|
v.literal("affiliates"),
|
|
1221
|
+
v.literal("vitals"),
|
|
988
1222
|
);
|
|
989
1223
|
|
|
990
1224
|
export const dataCoverage = query({
|
|
@@ -997,10 +1231,12 @@ export const dataCoverage = query({
|
|
|
997
1231
|
siteId: v.id("sites"),
|
|
998
1232
|
from: v.number(),
|
|
999
1233
|
to: v.number(),
|
|
1000
|
-
datasets: v.array(
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1234
|
+
datasets: v.array(
|
|
1235
|
+
v.object({
|
|
1236
|
+
dataset: coverageDatasetValidator,
|
|
1237
|
+
coverage: resultCoverageValidator,
|
|
1238
|
+
}),
|
|
1239
|
+
),
|
|
1004
1240
|
}),
|
|
1005
1241
|
handler: async (ctx, args) => {
|
|
1006
1242
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
@@ -1013,21 +1249,24 @@ export const dataCoverage = query({
|
|
|
1013
1249
|
"goals",
|
|
1014
1250
|
"funnels",
|
|
1015
1251
|
"affiliates",
|
|
1252
|
+
"vitals",
|
|
1016
1253
|
] as const;
|
|
1017
1254
|
return {
|
|
1018
1255
|
siteId: args.siteId,
|
|
1019
1256
|
from: args.from,
|
|
1020
1257
|
to: args.to,
|
|
1021
|
-
datasets: await Promise.all(
|
|
1022
|
-
dataset
|
|
1023
|
-
coverage: await datasetCoverageForRange(
|
|
1024
|
-
ctx,
|
|
1025
|
-
args.siteId,
|
|
1258
|
+
datasets: await Promise.all(
|
|
1259
|
+
datasets.map(async (dataset) => ({
|
|
1026
1260
|
dataset,
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1261
|
+
coverage: await datasetCoverageForRange(
|
|
1262
|
+
ctx,
|
|
1263
|
+
args.siteId,
|
|
1264
|
+
dataset,
|
|
1265
|
+
args.from,
|
|
1266
|
+
args.to,
|
|
1267
|
+
),
|
|
1268
|
+
})),
|
|
1269
|
+
),
|
|
1031
1270
|
};
|
|
1032
1271
|
},
|
|
1033
1272
|
});
|
|
@@ -1137,7 +1376,11 @@ async function loadAffiliateDailyRollups(
|
|
|
1137
1376
|
.lte("bucketStart", to),
|
|
1138
1377
|
)
|
|
1139
1378
|
.take(perAffiliateLimit + 1);
|
|
1140
|
-
failIfIncomplete(
|
|
1379
|
+
failIfIncomplete(
|
|
1380
|
+
affiliateRows,
|
|
1381
|
+
perAffiliateLimit,
|
|
1382
|
+
"affiliate daily rollups",
|
|
1383
|
+
);
|
|
1141
1384
|
rows.push(...affiliateRows);
|
|
1142
1385
|
await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
|
|
1143
1386
|
}
|
|
@@ -1165,20 +1408,28 @@ async function requireFeatureReadHeadroom(ctx: QueryCtx, dataset: string) {
|
|
|
1165
1408
|
metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
|
|
1166
1409
|
metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE
|
|
1167
1410
|
) {
|
|
1168
|
-
fail(
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1411
|
+
fail(
|
|
1412
|
+
"REPORT_INCOMPLETE",
|
|
1413
|
+
`${dataset} exceeds its byte or document budget`,
|
|
1414
|
+
{
|
|
1415
|
+
dataset,
|
|
1416
|
+
bytesRead: metrics.bytesRead.used,
|
|
1417
|
+
documentsRead: metrics.documentsRead.used,
|
|
1418
|
+
},
|
|
1419
|
+
);
|
|
1173
1420
|
}
|
|
1174
1421
|
}
|
|
1175
1422
|
|
|
1176
1423
|
function failIfIncomplete<T>(rows: T[], maximum: number, dataset: string) {
|
|
1177
1424
|
if (rows.length > maximum) {
|
|
1178
|
-
fail(
|
|
1179
|
-
|
|
1180
|
-
dataset
|
|
1181
|
-
|
|
1425
|
+
fail(
|
|
1426
|
+
"REPORT_INCOMPLETE",
|
|
1427
|
+
`${dataset} exceeds the complete report read budget`,
|
|
1428
|
+
{
|
|
1429
|
+
maximumRows: maximum,
|
|
1430
|
+
dataset,
|
|
1431
|
+
},
|
|
1432
|
+
);
|
|
1182
1433
|
}
|
|
1183
1434
|
}
|
|
1184
1435
|
|
|
@@ -1193,45 +1444,52 @@ async function coverageForRange(
|
|
|
1193
1444
|
generation?: 1 | null;
|
|
1194
1445
|
},
|
|
1195
1446
|
) {
|
|
1196
|
-
const controls = await Promise.all(
|
|
1197
|
-
|
|
1198
|
-
.
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1447
|
+
const controls = await Promise.all(
|
|
1448
|
+
siteIds.map((siteId) =>
|
|
1449
|
+
ctx.db
|
|
1450
|
+
.query("analyticsCoverage")
|
|
1451
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1452
|
+
.unique(),
|
|
1453
|
+
),
|
|
1454
|
+
);
|
|
1455
|
+
const sourceRanges = await Promise.all(
|
|
1456
|
+
siteIds.map((siteId) => sourceSetRange(ctx, siteId, options.datasets)),
|
|
1457
|
+
);
|
|
1205
1458
|
const retainedBefore = controls.reduce<number | null>((latest, control) => {
|
|
1206
1459
|
const before = control?.retained
|
|
1207
|
-
.filter((item) =>
|
|
1460
|
+
.filter((item) =>
|
|
1461
|
+
options.datasets.some((dataset) => dataset === item.dataset),
|
|
1462
|
+
)
|
|
1208
1463
|
.reduce<number | undefined>(
|
|
1209
1464
|
(latest, item) => Math.max(latest ?? 0, item.before),
|
|
1210
1465
|
undefined,
|
|
1211
1466
|
);
|
|
1212
1467
|
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
1213
1468
|
}, null);
|
|
1214
|
-
const availableFrom =
|
|
1215
|
-
|
|
1216
|
-
?
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1469
|
+
const availableFrom =
|
|
1470
|
+
options.generation === 1
|
|
1471
|
+
? controls.length > 0 && controls.every(Boolean)
|
|
1472
|
+
? Math.max(...controls.map((control) => control!.rollupCompleteFrom))
|
|
1473
|
+
: null
|
|
1474
|
+
: sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1475
|
+
? Math.max(...sourceRanges.map((range) => range!.from))
|
|
1476
|
+
: null;
|
|
1477
|
+
const availableThrough =
|
|
1478
|
+
sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1479
|
+
? Math.min(...sourceRanges.map((range) => range!.through))
|
|
1220
1480
|
: null;
|
|
1221
|
-
const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1222
|
-
? Math.min(...sourceRanges.map((range) => range!.through))
|
|
1223
|
-
: null;
|
|
1224
1481
|
let state: "complete" | "partial" | "retained" | "unavailable";
|
|
1225
1482
|
if (retainedBefore !== null && to < retainedBefore) {
|
|
1226
1483
|
state = "unavailable";
|
|
1227
1484
|
} else if (retainedBefore !== null && from < retainedBefore) {
|
|
1228
1485
|
state = "partial";
|
|
1229
|
-
} else if (
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1486
|
+
} else if (
|
|
1487
|
+
options.complete ||
|
|
1488
|
+
(availableFrom !== null &&
|
|
1489
|
+
availableThrough !== null &&
|
|
1490
|
+
from >= availableFrom &&
|
|
1491
|
+
to <= availableThrough)
|
|
1492
|
+
) {
|
|
1235
1493
|
state = "complete";
|
|
1236
1494
|
} else if (sourceRanges.some(Boolean)) {
|
|
1237
1495
|
state = "retained";
|
|
@@ -1253,12 +1511,14 @@ async function assertSourceAvailable(
|
|
|
1253
1511
|
sources: CoverageSource[],
|
|
1254
1512
|
from: number,
|
|
1255
1513
|
) {
|
|
1256
|
-
const rows = await Promise.all(
|
|
1257
|
-
|
|
1258
|
-
.
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1514
|
+
const rows = await Promise.all(
|
|
1515
|
+
siteIds.map((siteId) =>
|
|
1516
|
+
ctx.db
|
|
1517
|
+
.query("analyticsCoverage")
|
|
1518
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1519
|
+
.unique(),
|
|
1520
|
+
),
|
|
1521
|
+
);
|
|
1262
1522
|
const cutoff = rows.reduce<number | null>((latest, row) => {
|
|
1263
1523
|
for (const retained of row?.retained ?? []) {
|
|
1264
1524
|
if (sources.some((source) => source === retained.dataset)) {
|
|
@@ -1268,9 +1528,13 @@ async function assertSourceAvailable(
|
|
|
1268
1528
|
return latest;
|
|
1269
1529
|
}, null);
|
|
1270
1530
|
if (cutoff !== null && from < cutoff) {
|
|
1271
|
-
fail(
|
|
1272
|
-
|
|
1273
|
-
|
|
1531
|
+
fail(
|
|
1532
|
+
"REPORT_INCOMPLETE",
|
|
1533
|
+
"requested report range intersects retained data",
|
|
1534
|
+
{
|
|
1535
|
+
retainedBefore: cutoff,
|
|
1536
|
+
},
|
|
1537
|
+
);
|
|
1274
1538
|
}
|
|
1275
1539
|
}
|
|
1276
1540
|
|
|
@@ -1281,31 +1545,42 @@ async function datasetCoverageForRange(
|
|
|
1281
1545
|
from: number,
|
|
1282
1546
|
to: number,
|
|
1283
1547
|
) {
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
? [["funnelRollups"], ["funnelSteps"]]
|
|
1288
|
-
: dataset === "affiliates"
|
|
1289
|
-
? [["affiliateRollups"], ["affiliateStats", "sessions"]]
|
|
1290
|
-
: [[dataset]];
|
|
1291
|
-
const options = (await Promise.all(sourceOptions.map(async (sources) => {
|
|
1292
|
-
const rollupDataset = sources[0] as
|
|
1293
|
-
| "goalRollups"
|
|
1294
|
-
| "funnelRollups"
|
|
1295
|
-
| "affiliateRollups";
|
|
1296
|
-
const isRollup = rollupDataset.endsWith("Rollups");
|
|
1297
|
-
const complete = isRollup
|
|
1298
|
-
? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
|
|
1299
|
-
: false;
|
|
1300
|
-
if (isRollup && !complete) return null;
|
|
1548
|
+
// Vitals have no legacy fallback source; the rollup either covers the range
|
|
1549
|
+
// or the coverage states say how much of it exists.
|
|
1550
|
+
if (dataset === "vitals") {
|
|
1301
1551
|
return coverageForRange(ctx, [siteId], from, to, {
|
|
1302
|
-
datasets:
|
|
1303
|
-
complete,
|
|
1304
|
-
generation: complete ? 1 : null,
|
|
1552
|
+
datasets: ["vitalRollups"],
|
|
1553
|
+
complete: false,
|
|
1305
1554
|
});
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1555
|
+
}
|
|
1556
|
+
const sourceOptions: CoverageSource[][] =
|
|
1557
|
+
dataset === "goals"
|
|
1558
|
+
? [["goalRollups"], ["goalCompletions"]]
|
|
1559
|
+
: dataset === "funnels"
|
|
1560
|
+
? [["funnelRollups"], ["funnelSteps"]]
|
|
1561
|
+
: dataset === "affiliates"
|
|
1562
|
+
? [["affiliateRollups"], ["affiliateStats", "sessions"]]
|
|
1563
|
+
: [[dataset]];
|
|
1564
|
+
const options = (
|
|
1565
|
+
await Promise.all(
|
|
1566
|
+
sourceOptions.map(async (sources) => {
|
|
1567
|
+
const rollupDataset = sources[0] as
|
|
1568
|
+
"goalRollups" | "funnelRollups" | "affiliateRollups";
|
|
1569
|
+
const isRollup = rollupDataset.endsWith("Rollups");
|
|
1570
|
+
const complete = isRollup
|
|
1571
|
+
? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
|
|
1572
|
+
: false;
|
|
1573
|
+
if (isRollup && !complete) return null;
|
|
1574
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1575
|
+
datasets: sources,
|
|
1576
|
+
complete,
|
|
1577
|
+
generation: complete ? 1 : null,
|
|
1578
|
+
});
|
|
1579
|
+
}),
|
|
1580
|
+
)
|
|
1581
|
+
).filter((option): option is NonNullable<typeof option> => option !== null);
|
|
1582
|
+
return options.sort(
|
|
1583
|
+
(left, right) => coverageRank(right.state) - coverageRank(left.state),
|
|
1309
1584
|
)[0]!;
|
|
1310
1585
|
}
|
|
1311
1586
|
|
|
@@ -1317,7 +1592,8 @@ type PublicCoverageDataset =
|
|
|
1317
1592
|
| "conversions"
|
|
1318
1593
|
| "goals"
|
|
1319
1594
|
| "funnels"
|
|
1320
|
-
| "affiliates"
|
|
1595
|
+
| "affiliates"
|
|
1596
|
+
| "vitals";
|
|
1321
1597
|
|
|
1322
1598
|
type CoverageSource =
|
|
1323
1599
|
| "overviewHour"
|
|
@@ -1330,7 +1606,8 @@ type CoverageSource =
|
|
|
1330
1606
|
| "affiliateStats"
|
|
1331
1607
|
| "goalRollups"
|
|
1332
1608
|
| "funnelRollups"
|
|
1333
|
-
| "affiliateRollups"
|
|
1609
|
+
| "affiliateRollups"
|
|
1610
|
+
| "vitalRollups";
|
|
1334
1611
|
|
|
1335
1612
|
type SourceRange = { from: number; through: number };
|
|
1336
1613
|
|
|
@@ -1339,9 +1616,9 @@ async function sourceSetRange(
|
|
|
1339
1616
|
siteId: Id<"sites">,
|
|
1340
1617
|
sources: CoverageSource[],
|
|
1341
1618
|
): Promise<SourceRange | null> {
|
|
1342
|
-
const ranges = (
|
|
1343
|
-
sources.map((source) => sourceRange(ctx, siteId, source))
|
|
1344
|
-
)
|
|
1619
|
+
const ranges = (
|
|
1620
|
+
await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))
|
|
1621
|
+
).filter((range): range is SourceRange => range !== null);
|
|
1345
1622
|
if (ranges.length === 0) return null;
|
|
1346
1623
|
return {
|
|
1347
1624
|
from: Math.min(...ranges.map((range) => range.from)),
|
|
@@ -1355,79 +1632,129 @@ async function sourceRange(
|
|
|
1355
1632
|
source: CoverageSource,
|
|
1356
1633
|
): Promise<SourceRange | null> {
|
|
1357
1634
|
if (source === "overviewHour" || source === "overviewDay") {
|
|
1358
|
-
const granularity =
|
|
1635
|
+
const granularity =
|
|
1636
|
+
source === "overviewHour" ? ("hour" as const) : ("day" as const);
|
|
1359
1637
|
const [first, last] = await Promise.all([
|
|
1360
|
-
ctx.db
|
|
1638
|
+
ctx.db
|
|
1639
|
+
.query("aggregateBuckets")
|
|
1361
1640
|
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
1362
1641
|
range.eq("siteId", siteId).eq("granularity", granularity),
|
|
1363
|
-
)
|
|
1364
|
-
|
|
1642
|
+
)
|
|
1643
|
+
.order("asc")
|
|
1644
|
+
.take(1),
|
|
1645
|
+
ctx.db
|
|
1646
|
+
.query("aggregateBuckets")
|
|
1365
1647
|
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
1366
1648
|
range.eq("siteId", siteId).eq("granularity", granularity),
|
|
1367
|
-
)
|
|
1649
|
+
)
|
|
1650
|
+
.order("desc")
|
|
1651
|
+
.take(1),
|
|
1368
1652
|
]);
|
|
1369
1653
|
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1370
1654
|
}
|
|
1371
1655
|
if (source === "events") {
|
|
1372
1656
|
const [first, last] = await Promise.all([
|
|
1373
|
-
ctx.db
|
|
1374
|
-
.
|
|
1375
|
-
.
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
.order("
|
|
1657
|
+
ctx.db
|
|
1658
|
+
.query("eventBatches")
|
|
1659
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
1660
|
+
range.eq("siteId", siteId),
|
|
1661
|
+
)
|
|
1662
|
+
.order("asc")
|
|
1663
|
+
.take(1),
|
|
1664
|
+
ctx.db
|
|
1665
|
+
.query("eventBatches")
|
|
1666
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
1667
|
+
range.eq("siteId", siteId),
|
|
1668
|
+
)
|
|
1669
|
+
.order("desc")
|
|
1670
|
+
.take(1),
|
|
1379
1671
|
]);
|
|
1380
1672
|
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1381
1673
|
}
|
|
1382
1674
|
if (source === "sessions") {
|
|
1383
1675
|
const [first, last] = await Promise.all([
|
|
1384
|
-
ctx.db
|
|
1385
|
-
.
|
|
1386
|
-
.
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
.order("
|
|
1676
|
+
ctx.db
|
|
1677
|
+
.query("sessions")
|
|
1678
|
+
.withIndex("by_siteId_and_startedAt", (range) =>
|
|
1679
|
+
range.eq("siteId", siteId),
|
|
1680
|
+
)
|
|
1681
|
+
.order("asc")
|
|
1682
|
+
.take(1),
|
|
1683
|
+
ctx.db
|
|
1684
|
+
.query("sessions")
|
|
1685
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) =>
|
|
1686
|
+
range.eq("siteId", siteId),
|
|
1687
|
+
)
|
|
1688
|
+
.order("desc")
|
|
1689
|
+
.take(1),
|
|
1390
1690
|
]);
|
|
1391
1691
|
return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
|
|
1392
1692
|
}
|
|
1393
1693
|
if (source === "conversions") {
|
|
1394
1694
|
const [first, last] = await Promise.all([
|
|
1395
|
-
ctx.db
|
|
1396
|
-
.
|
|
1397
|
-
.
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
.order("
|
|
1695
|
+
ctx.db
|
|
1696
|
+
.query("conversions")
|
|
1697
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
1698
|
+
range.eq("siteId", siteId),
|
|
1699
|
+
)
|
|
1700
|
+
.order("asc")
|
|
1701
|
+
.take(1),
|
|
1702
|
+
ctx.db
|
|
1703
|
+
.query("conversions")
|
|
1704
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
1705
|
+
range.eq("siteId", siteId),
|
|
1706
|
+
)
|
|
1707
|
+
.order("desc")
|
|
1708
|
+
.take(1),
|
|
1401
1709
|
]);
|
|
1402
1710
|
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1403
1711
|
}
|
|
1404
1712
|
if (source === "goalCompletions") {
|
|
1405
1713
|
const [first, last] = await Promise.all([
|
|
1406
|
-
ctx.db
|
|
1407
|
-
.
|
|
1408
|
-
.
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
.order("
|
|
1714
|
+
ctx.db
|
|
1715
|
+
.query("goalCompletions")
|
|
1716
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
1717
|
+
range.eq("siteId", siteId),
|
|
1718
|
+
)
|
|
1719
|
+
.order("asc")
|
|
1720
|
+
.take(1),
|
|
1721
|
+
ctx.db
|
|
1722
|
+
.query("goalCompletions")
|
|
1723
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
1724
|
+
range.eq("siteId", siteId),
|
|
1725
|
+
)
|
|
1726
|
+
.order("desc")
|
|
1727
|
+
.take(1),
|
|
1412
1728
|
]);
|
|
1413
1729
|
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1414
1730
|
}
|
|
1415
|
-
const table =
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1731
|
+
const table =
|
|
1732
|
+
source === "funnelSteps"
|
|
1733
|
+
? ("funnelStepBuckets" as const)
|
|
1734
|
+
: source === "affiliateStats"
|
|
1735
|
+
? ("affiliateDailyStats" as const)
|
|
1736
|
+
: source === "goalRollups"
|
|
1737
|
+
? ("goalDailyRollups" as const)
|
|
1738
|
+
: source === "funnelRollups"
|
|
1739
|
+
? ("funnelDailyRollups" as const)
|
|
1740
|
+
: source === "vitalRollups"
|
|
1741
|
+
? ("vitalRollups" as const)
|
|
1742
|
+
: ("affiliateDailyRollups" as const);
|
|
1424
1743
|
const [first, last] = await Promise.all([
|
|
1425
|
-
ctx.db
|
|
1426
|
-
.
|
|
1427
|
-
.
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
.order("
|
|
1744
|
+
ctx.db
|
|
1745
|
+
.query(table)
|
|
1746
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
1747
|
+
range.eq("siteId", siteId),
|
|
1748
|
+
)
|
|
1749
|
+
.order("asc")
|
|
1750
|
+
.take(1),
|
|
1751
|
+
ctx.db
|
|
1752
|
+
.query(table)
|
|
1753
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
1754
|
+
range.eq("siteId", siteId),
|
|
1755
|
+
)
|
|
1756
|
+
.order("desc")
|
|
1757
|
+
.take(1),
|
|
1431
1758
|
]);
|
|
1432
1759
|
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1433
1760
|
}
|
|
@@ -1436,14 +1763,15 @@ function bounds(from: number | undefined, through: number | undefined) {
|
|
|
1436
1763
|
return from === undefined || through === undefined ? null : { from, through };
|
|
1437
1764
|
}
|
|
1438
1765
|
|
|
1439
|
-
function coverageRank(
|
|
1766
|
+
function coverageRank(
|
|
1767
|
+
state: "complete" | "partial" | "retained" | "unavailable",
|
|
1768
|
+
) {
|
|
1440
1769
|
if (state === "complete") return 3;
|
|
1441
1770
|
if (state === "retained") return 2;
|
|
1442
1771
|
if (state === "partial") return 1;
|
|
1443
1772
|
return 0;
|
|
1444
1773
|
}
|
|
1445
1774
|
|
|
1446
|
-
|
|
1447
1775
|
type MetricTotals = {
|
|
1448
1776
|
events: number;
|
|
1449
1777
|
pageviews: number;
|
|
@@ -1484,8 +1812,15 @@ function addMetrics(target: MetricTotals, source: MetricTotals) {
|
|
|
1484
1812
|
|
|
1485
1813
|
function validateSiteIds<T>(siteIds: T[]): T[] {
|
|
1486
1814
|
const unique = [...new Set(siteIds)];
|
|
1487
|
-
if (
|
|
1488
|
-
|
|
1815
|
+
if (
|
|
1816
|
+
unique.length < 1 ||
|
|
1817
|
+
unique.length > MAX_REPORT_SITES ||
|
|
1818
|
+
unique.length !== siteIds.length
|
|
1819
|
+
) {
|
|
1820
|
+
fail(
|
|
1821
|
+
"INVALID_ARGUMENT",
|
|
1822
|
+
`siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`,
|
|
1823
|
+
);
|
|
1489
1824
|
}
|
|
1490
1825
|
return unique;
|
|
1491
1826
|
}
|
|
@@ -1660,22 +1995,23 @@ function keysetPaginationResult<T>(
|
|
|
1660
1995
|
) {
|
|
1661
1996
|
const page = rows.slice(0, pagination.numItems);
|
|
1662
1997
|
const isDone = rows.length <= pagination.numItems;
|
|
1663
|
-
const next =
|
|
1664
|
-
? cursorFor(page[page.length - 1])
|
|
1665
|
-
: pagination.cursor;
|
|
1998
|
+
const next =
|
|
1999
|
+
page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
|
|
1666
2000
|
return {
|
|
1667
2001
|
page,
|
|
1668
2002
|
isDone,
|
|
1669
|
-
continueCursor: btoa(
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
2003
|
+
continueCursor: btoa(
|
|
2004
|
+
JSON.stringify({
|
|
2005
|
+
v: 2,
|
|
2006
|
+
k: pagination.scope.kind,
|
|
2007
|
+
s: pagination.scope.siteId,
|
|
2008
|
+
n: pagination.scope.sessionId ?? null,
|
|
2009
|
+
f: pagination.scope.from,
|
|
2010
|
+
t: pagination.scope.to,
|
|
2011
|
+
x: next?.timestamp ?? pagination.scope.to,
|
|
2012
|
+
c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
|
|
2013
|
+
}),
|
|
2014
|
+
),
|
|
1679
2015
|
};
|
|
1680
2016
|
}
|
|
1681
2017
|
|
|
@@ -1687,26 +2023,28 @@ function journeyPaginationResult(
|
|
|
1687
2023
|
const isDone = rows.length <= pagination.numItems;
|
|
1688
2024
|
const last = page.at(-1);
|
|
1689
2025
|
const next = last
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
2026
|
+
? {
|
|
2027
|
+
timestamp: last.timestamp,
|
|
2028
|
+
creationTime: last._creationTime,
|
|
2029
|
+
id: last._id,
|
|
1694
2030
|
}
|
|
1695
2031
|
: pagination.cursor;
|
|
1696
2032
|
return {
|
|
1697
2033
|
page,
|
|
1698
2034
|
isDone,
|
|
1699
|
-
continueCursor: btoa(
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
2035
|
+
continueCursor: btoa(
|
|
2036
|
+
JSON.stringify({
|
|
2037
|
+
v: 4,
|
|
2038
|
+
k: pagination.scope.kind,
|
|
2039
|
+
s: pagination.scope.siteId,
|
|
2040
|
+
n: pagination.scope.sessionId,
|
|
2041
|
+
f: pagination.scope.from,
|
|
2042
|
+
t: pagination.scope.to,
|
|
2043
|
+
x: next?.timestamp ?? pagination.scope.to,
|
|
2044
|
+
c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
|
|
2045
|
+
i: next?.id ?? "~",
|
|
2046
|
+
}),
|
|
2047
|
+
),
|
|
1710
2048
|
};
|
|
1711
2049
|
}
|
|
1712
2050
|
|
|
@@ -1715,8 +2053,15 @@ function validatePagination(options: {
|
|
|
1715
2053
|
maximumRowsRead?: number;
|
|
1716
2054
|
maximumBytesRead?: number;
|
|
1717
2055
|
}) {
|
|
1718
|
-
if (
|
|
1719
|
-
|
|
2056
|
+
if (
|
|
2057
|
+
!Number.isSafeInteger(options.numItems) ||
|
|
2058
|
+
options.numItems < 1 ||
|
|
2059
|
+
options.numItems > 100
|
|
2060
|
+
) {
|
|
2061
|
+
fail(
|
|
2062
|
+
"INVALID_ARGUMENT",
|
|
2063
|
+
"pagination numItems must be an integer between 1 and 100",
|
|
2064
|
+
);
|
|
1720
2065
|
}
|
|
1721
2066
|
if (
|
|
1722
2067
|
options.maximumRowsRead !== undefined &&
|
|
@@ -1724,7 +2069,10 @@ function validatePagination(options: {
|
|
|
1724
2069
|
options.maximumRowsRead < 1 ||
|
|
1725
2070
|
options.maximumRowsRead > 1_000)
|
|
1726
2071
|
) {
|
|
1727
|
-
fail(
|
|
2072
|
+
fail(
|
|
2073
|
+
"INVALID_ARGUMENT",
|
|
2074
|
+
"maximumRowsRead must be an integer between 1 and 1000",
|
|
2075
|
+
);
|
|
1728
2076
|
}
|
|
1729
2077
|
if (
|
|
1730
2078
|
options.maximumBytesRead !== undefined &&
|
|
@@ -1732,6 +2080,9 @@ function validatePagination(options: {
|
|
|
1732
2080
|
options.maximumBytesRead < 1 ||
|
|
1733
2081
|
options.maximumBytesRead > 8_000_000)
|
|
1734
2082
|
) {
|
|
1735
|
-
fail(
|
|
2083
|
+
fail(
|
|
2084
|
+
"INVALID_ARGUMENT",
|
|
2085
|
+
"maximumBytesRead must be an integer between 1 and 8000000",
|
|
2086
|
+
);
|
|
1736
2087
|
}
|
|
1737
2088
|
}
|