@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.10
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 +521 -66
- package/agent/integration.md +661 -0
- package/agent/manifest.json +187 -0
- package/agent/manifest.schema.json +411 -0
- package/dist/client/federation.d.ts +236 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +196 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +2467 -18
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +461 -32
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +16 -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 +302 -4
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +4 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.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 +12 -0
- package/dist/component/cardinality.d.ts.map +1 -0
- package/dist/component/cardinality.js +94 -0
- package/dist/component/cardinality.js.map +1 -0
- package/dist/component/constants.d.ts +25 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +38 -1
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +6 -1
- package/dist/component/convex.config.js +9 -1
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/coverage.d.ts +19 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +30 -0
- package/dist/component/coverage.js.map +1 -0
- package/dist/component/diagnostics.d.ts +9 -0
- package/dist/component/diagnostics.d.ts.map +1 -0
- package/dist/component/diagnostics.js +47 -0
- package/dist/component/diagnostics.js.map +1 -0
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/eventStore.d.ts +21 -9
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +142 -152
- 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 +71 -0
- package/dist/component/geo.d.ts.map +1 -0
- package/dist/component/geo.js +611 -0
- package/dist/component/geo.js.map +1 -0
- 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 +248 -49
- 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 +604 -55
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +7 -6
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +259 -10
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1110 -124
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +75 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +537 -32
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +24 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +96 -16
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +378 -62
- package/dist/component/schema.js +178 -20
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +11 -7
- 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 +101 -65
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +19 -13
- 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 +1 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +57 -20
- 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 +140 -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 +76 -76
- package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
- package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
- package/docs/federation-setup.md +397 -0
- package/docs/federation.md +262 -0
- package/docs/upgrading.md +259 -0
- package/llms.txt +71 -0
- package/package.json +55 -11
- package/scripts/benchmark-ingest.mjs +175 -73
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +366 -4
- package/src/component/_generated/server.ts +4 -0
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +117 -0
- package/src/component/constants.ts +38 -1
- package/src/component/convex.config.ts +11 -1
- package/src/component/coverage.ts +36 -0
- package/src/component/diagnostics.ts +65 -0
- package/src/component/errors.ts +2 -1
- package/src/component/eventStore.ts +217 -193
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +781 -0
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +357 -57
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +1040 -135
- package/src/component/live.ts +13 -7
- package/src/component/reports.ts +1675 -183
- package/src/component/retention.ts +770 -96
- package/src/component/sanitize.ts +144 -29
- package/src/component/schema.ts +200 -21
- package/src/component/sites.ts +27 -12
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +29 -13
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { paginationOptsValidator, paginationResultValidator, } from "convex/server";
|
|
2
2
|
import { v } from "convex/values";
|
|
3
3
|
import { query } from "./_generated/server.js";
|
|
4
|
-
import { AGGREGATE_SHARDS, DAY_MS, HOUR_MS, HOURLY_AGGREGATE_SHARDS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, MAX_OVERVIEW_AGGREGATE_ROWS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, } from "./constants.js";
|
|
4
|
+
import { AGGREGATE_SHARDS, DAY_MS, FEATURE_REPORT_BYTE_RESERVE, FEATURE_REPORT_DOCUMENT_RESERVE, HOUR_MS, HOURLY_AGGREGATE_SHARDS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_FEATURE_ROLLUP_DAYS, MAX_FEATURE_ROLLUP_ROWS, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, MAX_OVERVIEW_AGGREGATE_ROWS, MAX_REPORT_BUCKETS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, MAX_SITE_MAP_ROUTES_PER_DAY, MAX_SITE_MAP_ROWS, MAX_VITALS_REPORT_ROWS, REPORT_ROLLUP_SHARDS, ROUTE_TRANSITION_SLOTS, SITE_MAP_OTHER_ROUTE, VITALS_REPORT_PAGE_LIMIT, } from "./constants.js";
|
|
5
|
+
import { createVisitorSketch, estimateVisitors, unionVisitorSketches, } from "./cardinality.js";
|
|
5
6
|
import { fail } from "./errors.js";
|
|
6
|
-
import { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, } from "./eventStore.js";
|
|
7
|
+
import { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, loadVisitorJourneyEvents, } from "./eventStore.js";
|
|
7
8
|
import { isPlainRecord } from "./guards.js";
|
|
8
|
-
import { affiliateFieldsValidator, eventFieldsValidator, funnelFieldsValidator, goalFieldsValidator, sessionFieldsValidator, } from "./validators.js";
|
|
9
|
+
import { affiliateFieldsValidator, eventFieldsValidator, funnelFieldsValidator, goalFieldsValidator, sessionFieldsValidator, vitalMetricValidator, } from "./validators.js";
|
|
10
|
+
import { resolveVisitorIdentities } from "./visitors.js";
|
|
11
|
+
import { mergeVitalHistograms, VITAL_ALL, VITAL_METRICS, vitalHistogramPercentile, vitalRatingCounts, } from "./vitals.js";
|
|
9
12
|
const sessionDocumentValidator = sessionFieldsValidator.extend({
|
|
10
13
|
_id: v.id("sessions"),
|
|
11
14
|
_creationTime: v.number(),
|
|
@@ -34,6 +37,8 @@ const liveSessionDocumentValidator = v.object({
|
|
|
34
37
|
device: v.string(),
|
|
35
38
|
pageviewCount: v.number(),
|
|
36
39
|
eventCount: v.number(),
|
|
40
|
+
previousPath: v.optional(v.string()),
|
|
41
|
+
movedAt: v.optional(v.number()),
|
|
37
42
|
});
|
|
38
43
|
const conversionDocumentValidator = v.object({
|
|
39
44
|
_id: v.id("conversions"),
|
|
@@ -65,12 +70,33 @@ const metricTotalsValidator = v.object({
|
|
|
65
70
|
conversions: v.number(),
|
|
66
71
|
revenueCents: v.number(),
|
|
67
72
|
});
|
|
68
|
-
const timePointValidator = metricTotalsValidator.extend({
|
|
73
|
+
const timePointValidator = metricTotalsValidator.extend({
|
|
74
|
+
timestamp: v.number(),
|
|
75
|
+
});
|
|
69
76
|
const topItemValidator = v.object({
|
|
70
77
|
value: v.string(),
|
|
71
78
|
count: v.number(),
|
|
72
79
|
revenueCents: v.number(),
|
|
73
80
|
});
|
|
81
|
+
const coverageStateValidator = v.union(v.literal("complete"), v.literal("partial"), v.literal("retained"), v.literal("unavailable"));
|
|
82
|
+
const resultCoverageValidator = v.object({
|
|
83
|
+
state: coverageStateValidator,
|
|
84
|
+
generation: v.union(v.number(), v.null()),
|
|
85
|
+
availableFrom: v.union(v.number(), v.null()),
|
|
86
|
+
availableThrough: v.union(v.number(), v.null()),
|
|
87
|
+
retainedBefore: v.union(v.number(), v.null()),
|
|
88
|
+
});
|
|
89
|
+
const rangeSemanticsValidator = v.object({
|
|
90
|
+
from: v.number(),
|
|
91
|
+
to: v.number(),
|
|
92
|
+
boundary: v.literal("inclusive"),
|
|
93
|
+
timezone: v.literal("UTC"),
|
|
94
|
+
});
|
|
95
|
+
const reportMetadataValidator = v.object({
|
|
96
|
+
basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
|
|
97
|
+
coverage: resultCoverageValidator,
|
|
98
|
+
range: rangeSemanticsValidator,
|
|
99
|
+
});
|
|
74
100
|
export const overview = query({
|
|
75
101
|
args: {
|
|
76
102
|
siteIds: v.array(v.id("sites")),
|
|
@@ -86,12 +112,40 @@ export const overview = query({
|
|
|
86
112
|
totals: metricTotalsValidator,
|
|
87
113
|
timeSeries: v.array(timePointValidator),
|
|
88
114
|
topSources: v.array(topItemValidator),
|
|
115
|
+
topCampaigns: v.array(topItemValidator),
|
|
89
116
|
topPages: v.array(topItemValidator),
|
|
90
117
|
topCountries: v.array(topItemValidator),
|
|
91
118
|
topDevices: v.array(topItemValidator),
|
|
92
119
|
topBrowsers: v.array(topItemValidator),
|
|
93
120
|
topEvents: v.array(topItemValidator),
|
|
94
121
|
topGoals: v.array(topItemValidator),
|
|
122
|
+
metadata: v.object({
|
|
123
|
+
visitors: v.object({
|
|
124
|
+
basis: v.union(v.literal("estimatedUnique"), v.literal("bucketOccurrences")),
|
|
125
|
+
exact: v.boolean(),
|
|
126
|
+
legacyIncomplete: v.boolean(),
|
|
127
|
+
sketchVersion: v.union(v.number(), v.null()),
|
|
128
|
+
precision: v.union(v.number(), v.null()),
|
|
129
|
+
}),
|
|
130
|
+
breakdowns: v.object({
|
|
131
|
+
topSources: v.literal("eventVolume"),
|
|
132
|
+
topCampaigns: v.literal("eventVolume"),
|
|
133
|
+
topPages: v.literal("eventVolume"),
|
|
134
|
+
topCountries: v.literal("eventVolume"),
|
|
135
|
+
topDevices: v.literal("eventVolume"),
|
|
136
|
+
topBrowsers: v.literal("eventVolume"),
|
|
137
|
+
topEvents: v.literal("eventVolume"),
|
|
138
|
+
topGoals: v.literal("goalCompletionVolume"),
|
|
139
|
+
}),
|
|
140
|
+
coverage: resultCoverageValidator,
|
|
141
|
+
range: rangeSemanticsValidator,
|
|
142
|
+
features: v.object({
|
|
143
|
+
sessionReplay: v.literal("unsupported"),
|
|
144
|
+
errorInsights: v.literal("unsupported"),
|
|
145
|
+
outboundLinks: v.literal("eventVolume"),
|
|
146
|
+
botDetection: v.literal("userAgent"),
|
|
147
|
+
}),
|
|
148
|
+
}),
|
|
95
149
|
}),
|
|
96
150
|
handler: async (ctx, args) => {
|
|
97
151
|
const siteIds = validateSiteIds(args.siteIds);
|
|
@@ -114,6 +168,7 @@ export const overview = query({
|
|
|
114
168
|
fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
|
|
115
169
|
}
|
|
116
170
|
const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
|
|
171
|
+
requireAlignedRange(from, to, intervalMs, `${interval} overview`);
|
|
117
172
|
const firstBucket = Math.floor(from / intervalMs) * intervalMs;
|
|
118
173
|
const lastBucket = Math.floor(to / intervalMs) * intervalMs;
|
|
119
174
|
const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
|
|
@@ -124,7 +179,15 @@ export const overview = query({
|
|
|
124
179
|
}
|
|
125
180
|
const totals = emptyTotals();
|
|
126
181
|
const points = new Map();
|
|
182
|
+
const visitorSketch = createVisitorSketch();
|
|
183
|
+
let mergedVisitorSketch = visitorSketch;
|
|
184
|
+
const pointVisitorSketches = new Map();
|
|
185
|
+
let visitorsComplete = true;
|
|
127
186
|
const dimensions = new Map();
|
|
187
|
+
const overviewDataset = interval === "hour"
|
|
188
|
+
? "overviewHour"
|
|
189
|
+
: "overviewDay";
|
|
190
|
+
await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
|
|
128
191
|
for (const siteId of siteIds) {
|
|
129
192
|
const buckets = await ctx.db
|
|
130
193
|
.query("aggregateBuckets")
|
|
@@ -133,12 +196,21 @@ export const overview = query({
|
|
|
133
196
|
.eq("granularity", interval)
|
|
134
197
|
.gte("bucketStart", firstBucket)
|
|
135
198
|
.lte("bucketStart", lastBucket))
|
|
136
|
-
.take(perSiteLimit);
|
|
199
|
+
.take(perSiteLimit + 1);
|
|
200
|
+
failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
|
|
137
201
|
for (const bucket of buckets) {
|
|
138
202
|
addMetrics(totals, bucket);
|
|
139
203
|
const point = points.get(bucket.bucketStart) ?? emptyTotals();
|
|
140
204
|
addMetrics(point, bucket);
|
|
141
205
|
points.set(bucket.bucketStart, point);
|
|
206
|
+
if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
|
|
207
|
+
mergedVisitorSketch = unionVisitorSketches(mergedVisitorSketch, bucket.visitorSketch);
|
|
208
|
+
pointVisitorSketches.set(bucket.bucketStart, unionVisitorSketches(pointVisitorSketches.get(bucket.bucketStart) ??
|
|
209
|
+
createVisitorSketch(bucket.visitorSketch.precision), bucket.visitorSketch));
|
|
210
|
+
}
|
|
211
|
+
else if (bucket.events > 0) {
|
|
212
|
+
visitorsComplete = false;
|
|
213
|
+
}
|
|
142
214
|
for (const dimension of bucket.dimensions) {
|
|
143
215
|
const key = `${dimension.type}:${dimension.value}`;
|
|
144
216
|
const current = dimensions.get(key) ?? {
|
|
@@ -157,6 +229,17 @@ export const overview = query({
|
|
|
157
229
|
.map(([, value]) => value)
|
|
158
230
|
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
|
|
159
231
|
.slice(0, 10);
|
|
232
|
+
if (visitorsComplete) {
|
|
233
|
+
totals.visitors = estimateVisitors(mergedVisitorSketch);
|
|
234
|
+
for (const [timestamp, point] of points) {
|
|
235
|
+
point.visitors = estimateVisitors(pointVisitorSketches.get(timestamp) ?? createVisitorSketch());
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const coverage = await coverageForRange(ctx, siteIds, from, to, {
|
|
239
|
+
datasets: [overviewDataset],
|
|
240
|
+
complete: visitorsComplete,
|
|
241
|
+
});
|
|
242
|
+
const visitorBasis = visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
|
|
160
243
|
return {
|
|
161
244
|
from,
|
|
162
245
|
to,
|
|
@@ -167,12 +250,40 @@ export const overview = query({
|
|
|
167
250
|
.sort(([left], [right]) => left - right)
|
|
168
251
|
.map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
|
|
169
252
|
topSources: top("source"),
|
|
253
|
+
topCampaigns: top("campaign"),
|
|
170
254
|
topPages: top("page"),
|
|
171
255
|
topCountries: top("country"),
|
|
172
256
|
topDevices: top("device"),
|
|
173
257
|
topBrowsers: top("browser"),
|
|
174
258
|
topEvents: top("event"),
|
|
175
259
|
topGoals: top("goal"),
|
|
260
|
+
metadata: {
|
|
261
|
+
visitors: {
|
|
262
|
+
basis: visitorBasis,
|
|
263
|
+
exact: false,
|
|
264
|
+
legacyIncomplete: !visitorsComplete,
|
|
265
|
+
sketchVersion: visitorsComplete ? 1 : null,
|
|
266
|
+
precision: visitorsComplete ? 6 : null,
|
|
267
|
+
},
|
|
268
|
+
breakdowns: {
|
|
269
|
+
topSources: "eventVolume",
|
|
270
|
+
topCampaigns: "eventVolume",
|
|
271
|
+
topPages: "eventVolume",
|
|
272
|
+
topCountries: "eventVolume",
|
|
273
|
+
topDevices: "eventVolume",
|
|
274
|
+
topBrowsers: "eventVolume",
|
|
275
|
+
topEvents: "eventVolume",
|
|
276
|
+
topGoals: "goalCompletionVolume",
|
|
277
|
+
},
|
|
278
|
+
coverage,
|
|
279
|
+
range: rangeSemantics(from, to),
|
|
280
|
+
features: {
|
|
281
|
+
sessionReplay: "unsupported",
|
|
282
|
+
errorInsights: "unsupported",
|
|
283
|
+
outboundLinks: "eventVolume",
|
|
284
|
+
botDetection: "userAgent",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
176
287
|
};
|
|
177
288
|
},
|
|
178
289
|
});
|
|
@@ -186,7 +297,9 @@ export const liveVisitors = query({
|
|
|
186
297
|
handler: async (ctx, args) => {
|
|
187
298
|
const siteIds = validateSiteIds(args.siteIds);
|
|
188
299
|
const limit = args.limit ?? 100;
|
|
189
|
-
if (!Number.isSafeInteger(limit) ||
|
|
300
|
+
if (!Number.isSafeInteger(limit) ||
|
|
301
|
+
limit < 1 ||
|
|
302
|
+
limit > MAX_LIVE_VISITORS) {
|
|
190
303
|
fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`);
|
|
191
304
|
}
|
|
192
305
|
if (!Number.isFinite(args.now) || args.now < 0) {
|
|
@@ -223,9 +336,241 @@ export const liveVisitors = query({
|
|
|
223
336
|
device: row.device,
|
|
224
337
|
pageviewCount: row.pageviewCount,
|
|
225
338
|
eventCount: row.eventCount,
|
|
339
|
+
...(row.previousPath ? { previousPath: row.previousPath } : {}),
|
|
340
|
+
...(row.movedAt !== undefined ? { movedAt: row.movedAt } : {}),
|
|
226
341
|
}));
|
|
227
342
|
},
|
|
228
343
|
});
|
|
344
|
+
const siteMapRouteValidator = v.object({
|
|
345
|
+
route: v.string(),
|
|
346
|
+
pageviews: v.number(),
|
|
347
|
+
visitors: v.number(),
|
|
348
|
+
entries: v.number(),
|
|
349
|
+
exits: v.number(),
|
|
350
|
+
bounces: v.number(),
|
|
351
|
+
transitionsIn: v.number(),
|
|
352
|
+
transitionsOut: v.number(),
|
|
353
|
+
dwellMs: v.number(),
|
|
354
|
+
dwellSamples: v.number(),
|
|
355
|
+
daily: v.array(v.object({ bucketStart: v.number(), pageviews: v.number() })),
|
|
356
|
+
});
|
|
357
|
+
const siteMapTransitionValidator = v.object({
|
|
358
|
+
from: v.string(),
|
|
359
|
+
to: v.string(),
|
|
360
|
+
count: v.number(),
|
|
361
|
+
});
|
|
362
|
+
const siteMapCoverageDataset = "siteMapRollups";
|
|
363
|
+
/**
|
|
364
|
+
* Every route the sites served in the range and the transitions between
|
|
365
|
+
* them, read from the daily route rollups newest day first. Exits and
|
|
366
|
+
* bounces are derived rather than counted: a session enters once, leaves
|
|
367
|
+
* once, and every later pageview is a transition, so per route
|
|
368
|
+
* `exits = entries + transitionsIn - transitionsOut` and
|
|
369
|
+
* `bounces = entries - continued` hold without a session-end hook. The
|
|
370
|
+
* report never falls back to raw telemetry; days it could not cover are
|
|
371
|
+
* declared through `coverage` and `window`.
|
|
372
|
+
*/
|
|
373
|
+
export const siteMap = query({
|
|
374
|
+
args: {
|
|
375
|
+
siteIds: v.array(v.id("sites")),
|
|
376
|
+
from: v.number(),
|
|
377
|
+
to: v.number(),
|
|
378
|
+
},
|
|
379
|
+
returns: v.object({
|
|
380
|
+
from: v.number(),
|
|
381
|
+
to: v.number(),
|
|
382
|
+
window: v.union(v.object({ from: v.number(), to: v.number() }), v.null()),
|
|
383
|
+
routes: v.array(siteMapRouteValidator),
|
|
384
|
+
transitions: v.array(siteMapTransitionValidator),
|
|
385
|
+
truncated: v.object({ routes: v.boolean(), transitions: v.boolean() }),
|
|
386
|
+
metadata: reportMetadataValidator.extend({
|
|
387
|
+
visitors: v.object({
|
|
388
|
+
basis: v.literal("estimatedUnique"),
|
|
389
|
+
exact: v.boolean(),
|
|
390
|
+
}),
|
|
391
|
+
limits: v.object({
|
|
392
|
+
routesPerDay: v.number(),
|
|
393
|
+
transitionSlots: v.number(),
|
|
394
|
+
rows: v.number(),
|
|
395
|
+
}),
|
|
396
|
+
}),
|
|
397
|
+
}),
|
|
398
|
+
handler: async (ctx, args) => {
|
|
399
|
+
const siteIds = validateSiteIds(args.siteIds);
|
|
400
|
+
validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
|
|
401
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "site map");
|
|
402
|
+
const controls = await Promise.all(siteIds.map((siteId) => ctx.db
|
|
403
|
+
.query("analyticsCoverage")
|
|
404
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
405
|
+
.unique()));
|
|
406
|
+
const since = controls.every((control) => control?.siteMapSince !== undefined)
|
|
407
|
+
? Math.max(...controls.map((control) => control.siteMapSince))
|
|
408
|
+
: null;
|
|
409
|
+
const retainedBefore = controls.reduce((latest, control) => {
|
|
410
|
+
const before = control?.retained.find((item) => item.dataset === siteMapCoverageDataset)?.before;
|
|
411
|
+
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
412
|
+
}, null);
|
|
413
|
+
const metadata = (state) => ({
|
|
414
|
+
basis: "dailyRollup",
|
|
415
|
+
coverage: {
|
|
416
|
+
state,
|
|
417
|
+
generation: 1,
|
|
418
|
+
availableFrom: since,
|
|
419
|
+
availableThrough: since === null ? null : args.to,
|
|
420
|
+
retainedBefore,
|
|
421
|
+
},
|
|
422
|
+
range: rangeSemantics(args.from, args.to),
|
|
423
|
+
visitors: { basis: "estimatedUnique", exact: false },
|
|
424
|
+
limits: {
|
|
425
|
+
routesPerDay: MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
426
|
+
transitionSlots: ROUTE_TRANSITION_SLOTS,
|
|
427
|
+
rows: MAX_SITE_MAP_ROWS,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
if (since === null ||
|
|
431
|
+
args.to < since ||
|
|
432
|
+
(retainedBefore !== null && args.to < retainedBefore)) {
|
|
433
|
+
return {
|
|
434
|
+
from: args.from,
|
|
435
|
+
to: args.to,
|
|
436
|
+
window: null,
|
|
437
|
+
routes: [],
|
|
438
|
+
transitions: [],
|
|
439
|
+
truncated: { routes: false, transitions: false },
|
|
440
|
+
metadata: metadata("unavailable"),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const readFrom = Math.max(args.from, Math.floor(since / DAY_MS) * DAY_MS, retainedBefore ?? 0);
|
|
444
|
+
let budget = MAX_SITE_MAP_ROWS;
|
|
445
|
+
let completeFrom = readFrom;
|
|
446
|
+
const rows = [];
|
|
447
|
+
for (const siteId of siteIds) {
|
|
448
|
+
const read = await readRouteRollups(ctx, siteId, readFrom, args.to, budget);
|
|
449
|
+
budget -= read.rows.length;
|
|
450
|
+
completeFrom = Math.max(completeFrom, read.completeFrom);
|
|
451
|
+
rows.push(...read.rows);
|
|
452
|
+
}
|
|
453
|
+
const windowFrom = Math.max(args.from, completeFrom, since, retainedBefore ?? 0);
|
|
454
|
+
const routes = new Map();
|
|
455
|
+
const totalsFor = (route) => {
|
|
456
|
+
let current = routes.get(route);
|
|
457
|
+
if (!current) {
|
|
458
|
+
current = {
|
|
459
|
+
pageviews: 0,
|
|
460
|
+
entries: 0,
|
|
461
|
+
continued: 0,
|
|
462
|
+
dwellMs: 0,
|
|
463
|
+
dwellSamples: 0,
|
|
464
|
+
transitionsIn: 0,
|
|
465
|
+
transitionsOut: 0,
|
|
466
|
+
sketch: createVisitorSketch(),
|
|
467
|
+
daily: new Map(),
|
|
468
|
+
};
|
|
469
|
+
routes.set(route, current);
|
|
470
|
+
}
|
|
471
|
+
return current;
|
|
472
|
+
};
|
|
473
|
+
const edges = new Map();
|
|
474
|
+
for (const row of rows) {
|
|
475
|
+
if (row.bucketStart < completeFrom)
|
|
476
|
+
continue;
|
|
477
|
+
const current = totalsFor(row.route);
|
|
478
|
+
current.pageviews += row.pageviews;
|
|
479
|
+
current.entries += row.entries;
|
|
480
|
+
current.continued += row.continued;
|
|
481
|
+
current.dwellMs += row.dwellMs;
|
|
482
|
+
current.dwellSamples += row.dwellSamples;
|
|
483
|
+
current.sketch = unionVisitorSketches(current.sketch, row.visitorSketch);
|
|
484
|
+
current.daily.set(row.bucketStart, (current.daily.get(row.bucketStart) ?? 0) + row.pageviews);
|
|
485
|
+
for (const target of row.outgoing) {
|
|
486
|
+
const key = `${row.route}\u0000${target.route}`;
|
|
487
|
+
const edge = edges.get(key) ?? {
|
|
488
|
+
from: row.route,
|
|
489
|
+
to: target.route,
|
|
490
|
+
count: 0,
|
|
491
|
+
};
|
|
492
|
+
edge.count += target.count;
|
|
493
|
+
edges.set(key, edge);
|
|
494
|
+
current.transitionsOut += target.count;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// A target without a row of its own in this window — folded past the
|
|
498
|
+
// daily cap, or already retained — counts under "(other)".
|
|
499
|
+
const transitions = new Map();
|
|
500
|
+
for (const edge of edges.values()) {
|
|
501
|
+
const to = routes.has(edge.to) ? edge.to : SITE_MAP_OTHER_ROUTE;
|
|
502
|
+
const key = `${edge.from}\u0000${to}`;
|
|
503
|
+
const merged = transitions.get(key) ?? { from: edge.from, to, count: 0 };
|
|
504
|
+
merged.count += edge.count;
|
|
505
|
+
transitions.set(key, merged);
|
|
506
|
+
totalsFor(to).transitionsIn += edge.count;
|
|
507
|
+
}
|
|
508
|
+
const routeRows = [...routes.entries()]
|
|
509
|
+
.map(([route, totals]) => ({
|
|
510
|
+
route,
|
|
511
|
+
pageviews: totals.pageviews,
|
|
512
|
+
visitors: estimateVisitors(totals.sketch),
|
|
513
|
+
entries: totals.entries,
|
|
514
|
+
exits: Math.max(0, totals.entries + totals.transitionsIn - totals.transitionsOut),
|
|
515
|
+
bounces: Math.max(0, totals.entries - totals.continued),
|
|
516
|
+
transitionsIn: totals.transitionsIn,
|
|
517
|
+
transitionsOut: totals.transitionsOut,
|
|
518
|
+
dwellMs: totals.dwellMs,
|
|
519
|
+
dwellSamples: totals.dwellSamples,
|
|
520
|
+
daily: [...totals.daily.entries()]
|
|
521
|
+
.sort(([left], [right]) => left - right)
|
|
522
|
+
.map(([bucketStart, pageviews]) => ({ bucketStart, pageviews })),
|
|
523
|
+
}))
|
|
524
|
+
.sort((left, right) => right.pageviews - left.pageviews ||
|
|
525
|
+
left.route.localeCompare(right.route));
|
|
526
|
+
const transitionRows = [...transitions.values()].sort((left, right) => right.count - left.count ||
|
|
527
|
+
left.from.localeCompare(right.from) ||
|
|
528
|
+
left.to.localeCompare(right.to));
|
|
529
|
+
return {
|
|
530
|
+
from: args.from,
|
|
531
|
+
to: args.to,
|
|
532
|
+
window: completeFrom > args.to ? null : { from: windowFrom, to: args.to },
|
|
533
|
+
routes: routeRows,
|
|
534
|
+
transitions: transitionRows,
|
|
535
|
+
truncated: {
|
|
536
|
+
routes: routes.has(SITE_MAP_OTHER_ROUTE),
|
|
537
|
+
transitions: transitionRows.some((transition) => transition.from === SITE_MAP_OTHER_ROUTE ||
|
|
538
|
+
transition.to === SITE_MAP_OTHER_ROUTE),
|
|
539
|
+
},
|
|
540
|
+
metadata: metadata(windowFrom > args.from ? "partial" : "complete"),
|
|
541
|
+
};
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
/**
|
|
545
|
+
* Reads a site's route rollups newest day first until the row budget or the
|
|
546
|
+
* transaction's read headroom runs out, then drops the day it could only
|
|
547
|
+
* read in part so every day it returns is complete.
|
|
548
|
+
*/
|
|
549
|
+
async function readRouteRollups(ctx, siteId, from, to, budget) {
|
|
550
|
+
const rows = [];
|
|
551
|
+
let cutAt = null;
|
|
552
|
+
const query = ctx.db
|
|
553
|
+
.query("routeDailyRollups")
|
|
554
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range
|
|
555
|
+
.eq("siteId", siteId)
|
|
556
|
+
.gte("bucketStart", from)
|
|
557
|
+
.lte("bucketStart", to))
|
|
558
|
+
.order("desc");
|
|
559
|
+
for await (const row of query) {
|
|
560
|
+
if (rows.length >= budget ||
|
|
561
|
+
(rows.length % 256 === 255 && !(await hasFeatureReadHeadroom(ctx)))) {
|
|
562
|
+
cutAt = row.bucketStart;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
rows.push(row);
|
|
566
|
+
}
|
|
567
|
+
if (cutAt === null)
|
|
568
|
+
return { rows, completeFrom: from };
|
|
569
|
+
while (rows.length > 0 && rows[rows.length - 1].bucketStart === cutAt) {
|
|
570
|
+
rows.pop();
|
|
571
|
+
}
|
|
572
|
+
return { rows, completeFrom: cutAt + DAY_MS };
|
|
573
|
+
}
|
|
229
574
|
export const listSessions = query({
|
|
230
575
|
args: {
|
|
231
576
|
siteId: v.id("sites"),
|
|
@@ -236,6 +581,7 @@ export const listSessions = query({
|
|
|
236
581
|
returns: paginationResultValidator(sessionDocumentValidator),
|
|
237
582
|
handler: async (ctx, args) => {
|
|
238
583
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
584
|
+
await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
|
|
239
585
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
240
586
|
kind: "sessions",
|
|
241
587
|
siteId: String(args.siteId),
|
|
@@ -274,7 +620,8 @@ export const listSessions = query({
|
|
|
274
620
|
.order("desc")
|
|
275
621
|
.take(take)));
|
|
276
622
|
}
|
|
277
|
-
|
|
623
|
+
const publicRows = rows.map(({ geoLookupAttemptedAt: _attemptedAt, lastPageviewAt: _lastPageviewAt, ...row }) => row);
|
|
624
|
+
return keysetPaginationResult(publicRows, pagination, (row) => ({
|
|
278
625
|
timestamp: row.lastSeenAt,
|
|
279
626
|
creationTime: row._creationTime,
|
|
280
627
|
}));
|
|
@@ -291,6 +638,7 @@ export const sessionJourney = query({
|
|
|
291
638
|
returns: paginationResultValidator(eventDocumentValidator),
|
|
292
639
|
handler: async (ctx, args) => {
|
|
293
640
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
641
|
+
await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
|
|
294
642
|
const pagination = parseJourneyPagination(args.paginationOpts, {
|
|
295
643
|
kind: "events",
|
|
296
644
|
siteId: String(args.siteId),
|
|
@@ -320,6 +668,7 @@ export const listConversions = query({
|
|
|
320
668
|
returns: paginationResultValidator(conversionDocumentValidator),
|
|
321
669
|
handler: async (ctx, args) => {
|
|
322
670
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
671
|
+
await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
|
|
323
672
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
324
673
|
kind: "conversions",
|
|
325
674
|
siteId: String(args.siteId),
|
|
@@ -376,46 +725,28 @@ export const visitorJourney = query({
|
|
|
376
725
|
handler: async (ctx, args) => {
|
|
377
726
|
const siteIds = validateSiteIds(args.siteIds);
|
|
378
727
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
728
|
+
await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
|
|
379
729
|
const limit = args.limit ?? 200;
|
|
380
|
-
if (!Number.isSafeInteger(limit) ||
|
|
730
|
+
if (!Number.isSafeInteger(limit) ||
|
|
731
|
+
limit < 1 ||
|
|
732
|
+
limit > MAX_JOURNEY_EVENTS) {
|
|
381
733
|
fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
|
|
382
734
|
}
|
|
383
|
-
const sessionGroups = await Promise.all(siteIds.map((siteId) => ctx.db
|
|
384
|
-
.query("sessions")
|
|
385
|
-
.withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) => range
|
|
386
|
-
.eq("siteId", siteId)
|
|
387
|
-
.eq("visitorId", args.visitorId)
|
|
388
|
-
.gte("lastSeenAt", args.from))
|
|
389
|
-
.order("asc")
|
|
390
|
-
.take(MAX_JOURNEY_EVENTS + 1)));
|
|
391
|
-
const sessions = sessionGroups
|
|
392
|
-
.flat()
|
|
393
|
-
.filter((session) => session.startedAt <= args.to)
|
|
394
|
-
.sort((left, right) => left.startedAt - right.startedAt)
|
|
395
|
-
.slice(0, MAX_JOURNEY_EVENTS);
|
|
396
735
|
const rows = [];
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
736
|
+
for (const siteId of siteIds) {
|
|
737
|
+
for (const visitorId of await resolveVisitorIdentities(ctx, siteId, args.visitorId)) {
|
|
738
|
+
const cutoff = rows.length >= limit ? rows[limit - 1].timestamp : args.to;
|
|
739
|
+
rows.push(...(await loadVisitorJourneyEvents(ctx, {
|
|
740
|
+
siteId,
|
|
741
|
+
visitorId,
|
|
742
|
+
from: args.from,
|
|
743
|
+
to: cutoff,
|
|
744
|
+
resultLimit: limit,
|
|
745
|
+
})));
|
|
746
|
+
rows.sort(compareJourneyEvents);
|
|
747
|
+
if (rows.length > limit)
|
|
748
|
+
rows.length = limit;
|
|
407
749
|
}
|
|
408
|
-
rows.push(...await loadSessionJourneyEvents(ctx, {
|
|
409
|
-
siteId: session.siteId,
|
|
410
|
-
sessionId: session.sessionId,
|
|
411
|
-
from: args.from,
|
|
412
|
-
to: Math.min(args.to, cutoff),
|
|
413
|
-
cursor: null,
|
|
414
|
-
resultLimit: resultIsFull ? limit : limit - rows.length,
|
|
415
|
-
}));
|
|
416
|
-
rows.sort(compareJourneyEvents);
|
|
417
|
-
if (rows.length > limit)
|
|
418
|
-
rows.length = limit;
|
|
419
750
|
}
|
|
420
751
|
return hydrateEventContexts(ctx, rows);
|
|
421
752
|
},
|
|
@@ -431,69 +762,126 @@ export const goalsReport = query({
|
|
|
431
762
|
returns: v.object({
|
|
432
763
|
goals: v.array(goalReportItemValidator),
|
|
433
764
|
truncated: v.boolean(),
|
|
765
|
+
metadata: reportMetadataValidator,
|
|
434
766
|
}),
|
|
435
767
|
handler: async (ctx, args) => {
|
|
436
768
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
437
|
-
const
|
|
769
|
+
const goalRows = await ctx.db
|
|
438
770
|
.query("goals")
|
|
439
771
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
440
|
-
.take(MAX_GOALS_PER_SITE);
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
.withIndex("by_siteId_and_timestamp", (range) => range
|
|
444
|
-
.eq("siteId", args.siteId)
|
|
445
|
-
.gte("timestamp", args.from)
|
|
446
|
-
.lte("timestamp", args.to))
|
|
447
|
-
.take(5_001);
|
|
772
|
+
.take(MAX_GOALS_PER_SITE + 1);
|
|
773
|
+
failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
|
|
774
|
+
const goals = goalRows;
|
|
448
775
|
const totals = new Map();
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
776
|
+
const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "goalRollups");
|
|
777
|
+
const rollups = useRollups
|
|
778
|
+
? await loadGoalDailyRollups(ctx, args.siteId, goals.map((goal) => goal._id), args.from, args.to)
|
|
779
|
+
: null;
|
|
780
|
+
if (rollups) {
|
|
781
|
+
for (const row of rollups) {
|
|
782
|
+
const key = String(row.goalId);
|
|
783
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
784
|
+
total.completions += row.completions;
|
|
785
|
+
total.valueCents += row.valueCents;
|
|
786
|
+
totals.set(key, total);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
await assertSourceAvailable(ctx, [args.siteId], ["goalCompletions"], args.from);
|
|
791
|
+
const completions = await ctx.db
|
|
792
|
+
.query("goalCompletions")
|
|
793
|
+
.withIndex("by_siteId_and_timestamp", (range) => range
|
|
794
|
+
.eq("siteId", args.siteId)
|
|
795
|
+
.gte("timestamp", args.from)
|
|
796
|
+
.lte("timestamp", args.to))
|
|
797
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
798
|
+
failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
|
|
799
|
+
for (const completion of completions) {
|
|
800
|
+
const key = String(completion.goalId);
|
|
801
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
802
|
+
total.completions += 1;
|
|
803
|
+
total.valueCents += completion.valueCents;
|
|
804
|
+
totals.set(key, total);
|
|
805
|
+
}
|
|
455
806
|
}
|
|
456
807
|
return {
|
|
457
808
|
goals: goals.map((goal) => ({
|
|
458
809
|
...goal,
|
|
459
810
|
...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
|
|
460
811
|
})),
|
|
461
|
-
truncated:
|
|
812
|
+
truncated: false,
|
|
813
|
+
metadata: {
|
|
814
|
+
basis: rollups ? "dailyRollup" : "boundedLegacy",
|
|
815
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
816
|
+
datasets: [rollups ? "goalRollups" : "goalCompletions"],
|
|
817
|
+
complete: true,
|
|
818
|
+
generation: rollups ? 1 : null,
|
|
819
|
+
}),
|
|
820
|
+
range: rangeSemantics(args.from, args.to),
|
|
821
|
+
},
|
|
462
822
|
};
|
|
463
823
|
},
|
|
464
824
|
});
|
|
465
825
|
const funnelReportItemValidator = funnelFieldsValidator.extend({
|
|
466
826
|
_id: v.id("funnels"),
|
|
467
827
|
_creationTime: v.number(),
|
|
468
|
-
stepsReport: v.array(v.object({
|
|
828
|
+
stepsReport: v.array(v.object({
|
|
829
|
+
step: v.number(),
|
|
830
|
+
name: v.string(),
|
|
831
|
+
entrants: v.number(),
|
|
832
|
+
completions: v.number(),
|
|
833
|
+
})),
|
|
469
834
|
truncated: v.boolean(),
|
|
835
|
+
metadata: reportMetadataValidator,
|
|
470
836
|
});
|
|
471
837
|
export const funnelsReport = query({
|
|
472
838
|
args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
|
|
473
839
|
returns: v.array(funnelReportItemValidator),
|
|
474
840
|
handler: async (ctx, args) => {
|
|
475
841
|
validateRange(args.from, args.to, 90);
|
|
476
|
-
|
|
842
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
|
|
843
|
+
const funnelRows = await ctx.db
|
|
477
844
|
.query("funnels")
|
|
478
845
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
479
|
-
.take(MAX_FUNNELS_PER_SITE);
|
|
846
|
+
.take(MAX_FUNNELS_PER_SITE + 1);
|
|
847
|
+
failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
|
|
848
|
+
const funnels = funnelRows;
|
|
849
|
+
const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "funnelRollups");
|
|
850
|
+
const rollups = useRollups
|
|
851
|
+
? await loadFunnelDailyRollups(ctx, args.siteId, funnels.map((funnel) => funnel._id), args.from, args.to)
|
|
852
|
+
: null;
|
|
853
|
+
if (!rollups) {
|
|
854
|
+
await assertSourceAvailable(ctx, [args.siteId], ["funnelSteps"], args.from);
|
|
855
|
+
}
|
|
856
|
+
const reportCoverage = await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
857
|
+
datasets: [rollups ? "funnelRollups" : "funnelSteps"],
|
|
858
|
+
complete: true,
|
|
859
|
+
generation: rollups ? 1 : null,
|
|
860
|
+
});
|
|
480
861
|
const reports = [];
|
|
481
862
|
for (const funnel of funnels) {
|
|
482
|
-
const buckets = await ctx.db
|
|
483
|
-
.query("funnelStepBuckets")
|
|
484
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
|
|
485
|
-
.eq("siteId", args.siteId)
|
|
486
|
-
.eq("funnelId", funnel._id)
|
|
487
|
-
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
488
|
-
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
489
|
-
.take(601);
|
|
490
863
|
const stepsReport = funnel.steps.map((step, index) => ({
|
|
491
864
|
step: index,
|
|
492
865
|
name: step.name,
|
|
493
866
|
entrants: 0,
|
|
494
867
|
completions: 0,
|
|
495
868
|
}));
|
|
496
|
-
|
|
869
|
+
const buckets = rollups
|
|
870
|
+
? rollups
|
|
871
|
+
.filter((row) => row.funnelId === funnel._id)
|
|
872
|
+
.flatMap((row) => row.steps)
|
|
873
|
+
: await ctx.db
|
|
874
|
+
.query("funnelStepBuckets")
|
|
875
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
|
|
876
|
+
.eq("siteId", args.siteId)
|
|
877
|
+
.eq("funnelId", funnel._id)
|
|
878
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
879
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
880
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
881
|
+
if (!rollups) {
|
|
882
|
+
failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
|
|
883
|
+
}
|
|
884
|
+
for (const bucket of buckets) {
|
|
497
885
|
const step = stepsReport[bucket.step];
|
|
498
886
|
if (step) {
|
|
499
887
|
step.entrants += bucket.entrants;
|
|
@@ -503,7 +891,14 @@ export const funnelsReport = query({
|
|
|
503
891
|
reports.push({
|
|
504
892
|
...funnel,
|
|
505
893
|
stepsReport,
|
|
506
|
-
truncated:
|
|
894
|
+
truncated: false,
|
|
895
|
+
metadata: {
|
|
896
|
+
basis: rollups
|
|
897
|
+
? "dailyRollup"
|
|
898
|
+
: "boundedLegacy",
|
|
899
|
+
coverage: reportCoverage,
|
|
900
|
+
range: rangeSemantics(args.from, args.to),
|
|
901
|
+
},
|
|
507
902
|
});
|
|
508
903
|
}
|
|
509
904
|
return reports;
|
|
@@ -523,54 +918,94 @@ export const affiliatesReport = query({
|
|
|
523
918
|
returns: v.object({
|
|
524
919
|
affiliates: v.array(affiliateReportItemValidator),
|
|
525
920
|
truncated: v.boolean(),
|
|
921
|
+
metadata: reportMetadataValidator.extend({
|
|
922
|
+
uniqueVisitors: v.object({
|
|
923
|
+
basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
|
|
924
|
+
exact: v.boolean(),
|
|
925
|
+
}),
|
|
926
|
+
}),
|
|
526
927
|
}),
|
|
527
928
|
handler: async (ctx, args) => {
|
|
528
929
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
930
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
|
|
529
931
|
const affiliateRows = await ctx.db
|
|
530
932
|
.query("affiliates")
|
|
531
933
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
532
934
|
.order("desc")
|
|
533
935
|
.take(MAX_AFFILIATES_PER_SITE + 1);
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
541
|
-
.take(5_001);
|
|
542
|
-
const sessions = await ctx.db
|
|
543
|
-
.query("sessions")
|
|
544
|
-
.withIndex("by_siteId_and_startedAt", (range) => range
|
|
545
|
-
.eq("siteId", args.siteId)
|
|
546
|
-
.gte("startedAt", args.from)
|
|
547
|
-
.lte("startedAt", args.to))
|
|
548
|
-
.take(5_001);
|
|
936
|
+
failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
|
|
937
|
+
const affiliates = affiliateRows;
|
|
938
|
+
const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "affiliateRollups");
|
|
939
|
+
const rollups = useRollups
|
|
940
|
+
? await loadAffiliateDailyRollups(ctx, args.siteId, affiliates.map((affiliate) => affiliate._id), args.from, args.to)
|
|
941
|
+
: null;
|
|
549
942
|
const visits = new Map();
|
|
550
|
-
for (const session of sessions.slice(0, 5_000)) {
|
|
551
|
-
if (!session.affiliateId)
|
|
552
|
-
continue;
|
|
553
|
-
const key = String(session.affiliateId);
|
|
554
|
-
const total = visits.get(key) ?? {
|
|
555
|
-
visits: 0,
|
|
556
|
-
visitors: new Set(),
|
|
557
|
-
};
|
|
558
|
-
total.visits += 1;
|
|
559
|
-
total.visitors.add(session.visitorId);
|
|
560
|
-
visits.set(key, total);
|
|
561
|
-
}
|
|
562
943
|
const totals = new Map();
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
944
|
+
if (rollups) {
|
|
945
|
+
for (const row of rollups) {
|
|
946
|
+
const key = String(row.affiliateId);
|
|
947
|
+
const traffic = visits.get(key) ?? {
|
|
948
|
+
visits: 0,
|
|
949
|
+
visitors: new Set(),
|
|
950
|
+
sketch: createVisitorSketch(),
|
|
951
|
+
};
|
|
952
|
+
traffic.visits += row.visits;
|
|
953
|
+
traffic.sketch = unionVisitorSketches(traffic.sketch, row.visitorSketch);
|
|
954
|
+
visits.set(key, traffic);
|
|
955
|
+
const total = totals.get(key) ?? {
|
|
956
|
+
conversions: 0,
|
|
957
|
+
revenueCents: 0,
|
|
958
|
+
commissionCents: 0,
|
|
959
|
+
};
|
|
960
|
+
total.conversions += row.conversions;
|
|
961
|
+
total.revenueCents += row.revenueCents;
|
|
962
|
+
total.commissionCents += row.commissionCents;
|
|
963
|
+
totals.set(key, total);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
await assertSourceAvailable(ctx, [args.siteId], ["affiliateStats", "sessions"], args.from);
|
|
968
|
+
const stats = await ctx.db
|
|
969
|
+
.query("affiliateDailyStats")
|
|
970
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range
|
|
971
|
+
.eq("siteId", args.siteId)
|
|
972
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
973
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
974
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
975
|
+
const sessions = await ctx.db
|
|
976
|
+
.query("sessions")
|
|
977
|
+
.withIndex("by_siteId_and_startedAt", (range) => range
|
|
978
|
+
.eq("siteId", args.siteId)
|
|
979
|
+
.gte("startedAt", args.from)
|
|
980
|
+
.lte("startedAt", args.to))
|
|
981
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
982
|
+
failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
|
|
983
|
+
failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
|
|
984
|
+
for (const session of sessions) {
|
|
985
|
+
if (!session.affiliateId)
|
|
986
|
+
continue;
|
|
987
|
+
const key = String(session.affiliateId);
|
|
988
|
+
const total = visits.get(key) ?? {
|
|
989
|
+
visits: 0,
|
|
990
|
+
visitors: new Set(),
|
|
991
|
+
sketch: createVisitorSketch(),
|
|
992
|
+
};
|
|
993
|
+
total.visits += 1;
|
|
994
|
+
total.visitors.add(session.visitorId);
|
|
995
|
+
visits.set(key, total);
|
|
996
|
+
}
|
|
997
|
+
for (const row of stats) {
|
|
998
|
+
const key = String(row.affiliateId);
|
|
999
|
+
const total = totals.get(key) ?? {
|
|
1000
|
+
conversions: 0,
|
|
1001
|
+
revenueCents: 0,
|
|
1002
|
+
commissionCents: 0,
|
|
1003
|
+
};
|
|
1004
|
+
total.conversions += row.conversions;
|
|
1005
|
+
total.revenueCents += row.revenueCents;
|
|
1006
|
+
total.commissionCents += row.commissionCents;
|
|
1007
|
+
totals.set(key, total);
|
|
1008
|
+
}
|
|
574
1009
|
}
|
|
575
1010
|
return {
|
|
576
1011
|
affiliates: affiliates.map((affiliate) => {
|
|
@@ -584,15 +1019,548 @@ export const affiliatesReport = query({
|
|
|
584
1019
|
...affiliate,
|
|
585
1020
|
...performance,
|
|
586
1021
|
visits: traffic?.visits ?? 0,
|
|
587
|
-
uniqueVisitors:
|
|
1022
|
+
uniqueVisitors: rollups
|
|
1023
|
+
? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
|
|
1024
|
+
: (traffic?.visitors.size ?? 0),
|
|
588
1025
|
};
|
|
589
1026
|
}),
|
|
590
|
-
truncated:
|
|
591
|
-
|
|
592
|
-
|
|
1027
|
+
truncated: false,
|
|
1028
|
+
metadata: {
|
|
1029
|
+
basis: rollups ? "dailyRollup" : "boundedLegacy",
|
|
1030
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
1031
|
+
datasets: rollups
|
|
1032
|
+
? ["affiliateRollups"]
|
|
1033
|
+
: ["affiliateStats", "sessions"],
|
|
1034
|
+
complete: true,
|
|
1035
|
+
generation: rollups ? 1 : null,
|
|
1036
|
+
}),
|
|
1037
|
+
range: rangeSemantics(args.from, args.to),
|
|
1038
|
+
uniqueVisitors: {
|
|
1039
|
+
basis: rollups
|
|
1040
|
+
? "estimatedUnique"
|
|
1041
|
+
: "exactLegacy",
|
|
1042
|
+
exact: !rollups,
|
|
1043
|
+
},
|
|
1044
|
+
},
|
|
593
1045
|
};
|
|
594
1046
|
},
|
|
595
1047
|
});
|
|
1048
|
+
const vitalStatsValidator = v.object({
|
|
1049
|
+
count: v.number(),
|
|
1050
|
+
p75: v.number(),
|
|
1051
|
+
average: v.number(),
|
|
1052
|
+
good: v.number(),
|
|
1053
|
+
needsImprovement: v.number(),
|
|
1054
|
+
poor: v.number(),
|
|
1055
|
+
});
|
|
1056
|
+
export const vitalsReport = query({
|
|
1057
|
+
args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
|
|
1058
|
+
returns: v.object({
|
|
1059
|
+
metrics: v.array(vitalStatsValidator.extend({
|
|
1060
|
+
metric: vitalMetricValidator,
|
|
1061
|
+
devices: v.array(vitalStatsValidator.extend({ device: v.string() })),
|
|
1062
|
+
pages: v.array(vitalStatsValidator.extend({ page: v.string() })),
|
|
1063
|
+
timeSeries: v.array(v.object({
|
|
1064
|
+
timestamp: v.number(),
|
|
1065
|
+
count: v.number(),
|
|
1066
|
+
p75: v.number(),
|
|
1067
|
+
})),
|
|
1068
|
+
})),
|
|
1069
|
+
truncated: v.boolean(),
|
|
1070
|
+
metadata: reportMetadataValidator,
|
|
1071
|
+
}),
|
|
1072
|
+
handler: async (ctx, args) => {
|
|
1073
|
+
validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
|
|
1074
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "vitals report");
|
|
1075
|
+
await assertSourceAvailable(ctx, [args.siteId], ["vitalRollups"], args.from);
|
|
1076
|
+
const rows = await ctx.db
|
|
1077
|
+
.query("vitalRollups")
|
|
1078
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range
|
|
1079
|
+
.eq("siteId", args.siteId)
|
|
1080
|
+
.gte("bucketStart", args.from)
|
|
1081
|
+
.lte("bucketStart", args.to))
|
|
1082
|
+
.take(MAX_VITALS_REPORT_ROWS + 1);
|
|
1083
|
+
failIfIncomplete(rows, MAX_VITALS_REPORT_ROWS, "vital rollups");
|
|
1084
|
+
const fold = (groups, metric, key, entry) => {
|
|
1085
|
+
const group = groups.get(metric) ?? new Map();
|
|
1086
|
+
const current = group.get(key) ?? { count: 0, sum: 0, histogram: [] };
|
|
1087
|
+
current.count += entry.count;
|
|
1088
|
+
current.sum += entry.sum;
|
|
1089
|
+
current.histogram = mergeVitalHistograms(current.histogram, entry.histogram);
|
|
1090
|
+
group.set(key, current);
|
|
1091
|
+
groups.set(metric, group);
|
|
1092
|
+
};
|
|
1093
|
+
const byDevice = new Map();
|
|
1094
|
+
const byPage = new Map();
|
|
1095
|
+
const byDay = new Map();
|
|
1096
|
+
for (const row of rows) {
|
|
1097
|
+
for (const entry of row.metrics) {
|
|
1098
|
+
if (row.page === VITAL_ALL) {
|
|
1099
|
+
// A device row carries the complete signal once, so overall totals
|
|
1100
|
+
// and the time series derive from this family alone.
|
|
1101
|
+
fold(byDevice, entry.metric, row.device, entry);
|
|
1102
|
+
fold(byDay, entry.metric, row.bucketStart, entry);
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
fold(byPage, entry.metric, row.page, entry);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const stats = (metric, accumulated) => ({
|
|
1110
|
+
count: accumulated.count,
|
|
1111
|
+
p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
|
|
1112
|
+
average: accumulated.count > 0
|
|
1113
|
+
? Math.round(accumulated.sum / accumulated.count)
|
|
1114
|
+
: 0,
|
|
1115
|
+
...vitalRatingCounts(accumulated.histogram, metric),
|
|
1116
|
+
});
|
|
1117
|
+
const metrics = VITAL_METRICS.map((metric) => {
|
|
1118
|
+
const overall = { count: 0, sum: 0, histogram: [] };
|
|
1119
|
+
for (const accumulated of byDevice.get(metric)?.values() ?? []) {
|
|
1120
|
+
overall.count += accumulated.count;
|
|
1121
|
+
overall.sum += accumulated.sum;
|
|
1122
|
+
overall.histogram = mergeVitalHistograms(overall.histogram, accumulated.histogram);
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
metric,
|
|
1126
|
+
...stats(metric, overall),
|
|
1127
|
+
devices: [...(byDevice.get(metric) ?? [])]
|
|
1128
|
+
.map(([device, accumulated]) => ({
|
|
1129
|
+
device,
|
|
1130
|
+
...stats(metric, accumulated),
|
|
1131
|
+
}))
|
|
1132
|
+
.sort((left, right) => right.count - left.count),
|
|
1133
|
+
pages: [...(byPage.get(metric) ?? [])]
|
|
1134
|
+
.map(([page, accumulated]) => ({
|
|
1135
|
+
page,
|
|
1136
|
+
...stats(metric, accumulated),
|
|
1137
|
+
}))
|
|
1138
|
+
.sort((left, right) => right.count - left.count || left.page.localeCompare(right.page))
|
|
1139
|
+
.slice(0, VITALS_REPORT_PAGE_LIMIT),
|
|
1140
|
+
timeSeries: [...(byDay.get(metric) ?? [])]
|
|
1141
|
+
.sort(([left], [right]) => left - right)
|
|
1142
|
+
.map(([timestamp, accumulated]) => ({
|
|
1143
|
+
timestamp,
|
|
1144
|
+
count: accumulated.count,
|
|
1145
|
+
p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
|
|
1146
|
+
})),
|
|
1147
|
+
};
|
|
1148
|
+
});
|
|
1149
|
+
return {
|
|
1150
|
+
metrics,
|
|
1151
|
+
truncated: false,
|
|
1152
|
+
metadata: {
|
|
1153
|
+
basis: "dailyRollup",
|
|
1154
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, { datasets: ["vitalRollups"], complete: true }),
|
|
1155
|
+
range: rangeSemantics(args.from, args.to),
|
|
1156
|
+
},
|
|
1157
|
+
};
|
|
1158
|
+
},
|
|
1159
|
+
});
|
|
1160
|
+
const coverageDatasetValidator = v.union(v.literal("overviewHour"), v.literal("overviewDay"), v.literal("events"), v.literal("sessions"), v.literal("conversions"), v.literal("goals"), v.literal("funnels"), v.literal("affiliates"), v.literal("vitals"), v.literal("siteMap"));
|
|
1161
|
+
export const dataCoverage = query({
|
|
1162
|
+
args: {
|
|
1163
|
+
siteId: v.id("sites"),
|
|
1164
|
+
from: v.number(),
|
|
1165
|
+
to: v.number(),
|
|
1166
|
+
},
|
|
1167
|
+
returns: v.object({
|
|
1168
|
+
siteId: v.id("sites"),
|
|
1169
|
+
from: v.number(),
|
|
1170
|
+
to: v.number(),
|
|
1171
|
+
datasets: v.array(v.object({
|
|
1172
|
+
dataset: coverageDatasetValidator,
|
|
1173
|
+
coverage: resultCoverageValidator,
|
|
1174
|
+
})),
|
|
1175
|
+
}),
|
|
1176
|
+
handler: async (ctx, args) => {
|
|
1177
|
+
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
1178
|
+
const datasets = [
|
|
1179
|
+
"overviewHour",
|
|
1180
|
+
"overviewDay",
|
|
1181
|
+
"events",
|
|
1182
|
+
"sessions",
|
|
1183
|
+
"conversions",
|
|
1184
|
+
"goals",
|
|
1185
|
+
"funnels",
|
|
1186
|
+
"affiliates",
|
|
1187
|
+
"vitals",
|
|
1188
|
+
"siteMap",
|
|
1189
|
+
];
|
|
1190
|
+
return {
|
|
1191
|
+
siteId: args.siteId,
|
|
1192
|
+
from: args.from,
|
|
1193
|
+
to: args.to,
|
|
1194
|
+
datasets: await Promise.all(datasets.map(async (dataset) => ({
|
|
1195
|
+
dataset,
|
|
1196
|
+
coverage: await datasetCoverageForRange(ctx, args.siteId, dataset, args.from, args.to),
|
|
1197
|
+
}))),
|
|
1198
|
+
};
|
|
1199
|
+
},
|
|
1200
|
+
});
|
|
1201
|
+
async function dailyRollupsAvailable(ctx, siteId, from, to, dataset) {
|
|
1202
|
+
if (!isAlignedRange(from, to, DAY_MS))
|
|
1203
|
+
return false;
|
|
1204
|
+
const coverage = await ctx.db
|
|
1205
|
+
.query("analyticsCoverage")
|
|
1206
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1207
|
+
.unique();
|
|
1208
|
+
if (!coverage || coverage.rollupCompleteFrom > from) {
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
const retainedBefore = coverage.retained.find((item) => item.dataset === dataset)?.before;
|
|
1212
|
+
return retainedBefore === undefined || from >= retainedBefore;
|
|
1213
|
+
}
|
|
1214
|
+
async function loadGoalDailyRollups(ctx, siteId, goalIds, from, to) {
|
|
1215
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1216
|
+
requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
|
|
1217
|
+
const rows = [];
|
|
1218
|
+
for (const goalId of goalIds) {
|
|
1219
|
+
const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1220
|
+
const goalRows = await ctx.db
|
|
1221
|
+
.query("goalDailyRollups")
|
|
1222
|
+
.withIndex("by_siteId_and_goalId_and_bucketStart", (range) => range
|
|
1223
|
+
.eq("siteId", siteId)
|
|
1224
|
+
.eq("goalId", goalId)
|
|
1225
|
+
.gte("bucketStart", from)
|
|
1226
|
+
.lte("bucketStart", to))
|
|
1227
|
+
.take(perGoalLimit + 1);
|
|
1228
|
+
failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
|
|
1229
|
+
rows.push(...goalRows);
|
|
1230
|
+
await requireFeatureReadHeadroom(ctx, "goal daily rollups");
|
|
1231
|
+
}
|
|
1232
|
+
return rows;
|
|
1233
|
+
}
|
|
1234
|
+
async function loadFunnelDailyRollups(ctx, siteId, funnelIds, from, to) {
|
|
1235
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1236
|
+
requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
|
|
1237
|
+
const rows = [];
|
|
1238
|
+
for (const funnelId of funnelIds) {
|
|
1239
|
+
const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1240
|
+
const funnelRows = await ctx.db
|
|
1241
|
+
.query("funnelDailyRollups")
|
|
1242
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
|
|
1243
|
+
.eq("siteId", siteId)
|
|
1244
|
+
.eq("funnelId", funnelId)
|
|
1245
|
+
.gte("bucketStart", from)
|
|
1246
|
+
.lte("bucketStart", to))
|
|
1247
|
+
.take(perFunnelLimit + 1);
|
|
1248
|
+
failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
|
|
1249
|
+
rows.push(...funnelRows);
|
|
1250
|
+
await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
|
|
1251
|
+
}
|
|
1252
|
+
return rows;
|
|
1253
|
+
}
|
|
1254
|
+
async function loadAffiliateDailyRollups(ctx, siteId, affiliateIds, from, to) {
|
|
1255
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1256
|
+
requireFeatureRollupBudget(affiliateIds.length, days, "affiliate daily rollups");
|
|
1257
|
+
const rows = [];
|
|
1258
|
+
for (const affiliateId of affiliateIds) {
|
|
1259
|
+
const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1260
|
+
const affiliateRows = await ctx.db
|
|
1261
|
+
.query("affiliateDailyRollups")
|
|
1262
|
+
.withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) => range
|
|
1263
|
+
.eq("siteId", siteId)
|
|
1264
|
+
.eq("affiliateId", affiliateId)
|
|
1265
|
+
.gte("bucketStart", from)
|
|
1266
|
+
.lte("bucketStart", to))
|
|
1267
|
+
.take(perAffiliateLimit + 1);
|
|
1268
|
+
failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
|
|
1269
|
+
rows.push(...affiliateRows);
|
|
1270
|
+
await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
|
|
1271
|
+
}
|
|
1272
|
+
return rows;
|
|
1273
|
+
}
|
|
1274
|
+
function requireFeatureRollupBudget(entities, days, dataset) {
|
|
1275
|
+
const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
|
|
1276
|
+
if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
|
|
1277
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
|
|
1278
|
+
dataset,
|
|
1279
|
+
maximumDays: MAX_FEATURE_ROLLUP_DAYS,
|
|
1280
|
+
maximumRows: MAX_FEATURE_ROLLUP_ROWS,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
async function hasFeatureReadHeadroom(ctx) {
|
|
1285
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
1286
|
+
return (metrics.bytesRead.remaining >= FEATURE_REPORT_BYTE_RESERVE &&
|
|
1287
|
+
metrics.documentsRead.remaining >= FEATURE_REPORT_DOCUMENT_RESERVE);
|
|
1288
|
+
}
|
|
1289
|
+
async function requireFeatureReadHeadroom(ctx, dataset) {
|
|
1290
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
1291
|
+
if (metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
|
|
1292
|
+
metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE) {
|
|
1293
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
|
|
1294
|
+
dataset,
|
|
1295
|
+
bytesRead: metrics.bytesRead.used,
|
|
1296
|
+
documentsRead: metrics.documentsRead.used,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
function failIfIncomplete(rows, maximum, dataset) {
|
|
1301
|
+
if (rows.length > maximum) {
|
|
1302
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
|
|
1303
|
+
maximumRows: maximum,
|
|
1304
|
+
dataset,
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
async function coverageForRange(ctx, siteIds, from, to, options) {
|
|
1309
|
+
const controls = await Promise.all(siteIds.map((siteId) => ctx.db
|
|
1310
|
+
.query("analyticsCoverage")
|
|
1311
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1312
|
+
.unique()));
|
|
1313
|
+
const sourceRanges = await Promise.all(siteIds.map((siteId) => sourceSetRange(ctx, siteId, options.datasets)));
|
|
1314
|
+
const retainedBefore = controls.reduce((latest, control) => {
|
|
1315
|
+
const before = control?.retained
|
|
1316
|
+
.filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
|
|
1317
|
+
.reduce((latest, item) => Math.max(latest ?? 0, item.before), undefined);
|
|
1318
|
+
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
1319
|
+
}, null);
|
|
1320
|
+
const availableFrom = options.generation === 1
|
|
1321
|
+
? controls.length > 0 && controls.every(Boolean)
|
|
1322
|
+
? Math.max(...controls.map((control) => control.rollupCompleteFrom))
|
|
1323
|
+
: null
|
|
1324
|
+
: sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1325
|
+
? Math.max(...sourceRanges.map((range) => range.from))
|
|
1326
|
+
: null;
|
|
1327
|
+
const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1328
|
+
? Math.min(...sourceRanges.map((range) => range.through))
|
|
1329
|
+
: null;
|
|
1330
|
+
let state;
|
|
1331
|
+
if (retainedBefore !== null && to < retainedBefore) {
|
|
1332
|
+
state = "unavailable";
|
|
1333
|
+
}
|
|
1334
|
+
else if (retainedBefore !== null && from < retainedBefore) {
|
|
1335
|
+
state = "partial";
|
|
1336
|
+
}
|
|
1337
|
+
else if (options.complete ||
|
|
1338
|
+
(availableFrom !== null &&
|
|
1339
|
+
availableThrough !== null &&
|
|
1340
|
+
from >= availableFrom &&
|
|
1341
|
+
to <= availableThrough)) {
|
|
1342
|
+
state = "complete";
|
|
1343
|
+
}
|
|
1344
|
+
else if (sourceRanges.some(Boolean)) {
|
|
1345
|
+
state = "retained";
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
state = "unavailable";
|
|
1349
|
+
}
|
|
1350
|
+
return {
|
|
1351
|
+
state,
|
|
1352
|
+
generation: options.generation ?? null,
|
|
1353
|
+
availableFrom,
|
|
1354
|
+
availableThrough,
|
|
1355
|
+
retainedBefore,
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
async function assertSourceAvailable(ctx, siteIds, sources, from) {
|
|
1359
|
+
const rows = await Promise.all(siteIds.map((siteId) => ctx.db
|
|
1360
|
+
.query("analyticsCoverage")
|
|
1361
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1362
|
+
.unique()));
|
|
1363
|
+
const cutoff = rows.reduce((latest, row) => {
|
|
1364
|
+
for (const retained of row?.retained ?? []) {
|
|
1365
|
+
if (sources.some((source) => source === retained.dataset)) {
|
|
1366
|
+
latest = Math.max(latest ?? 0, retained.before);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return latest;
|
|
1370
|
+
}, null);
|
|
1371
|
+
if (cutoff !== null && from < cutoff) {
|
|
1372
|
+
fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
|
|
1373
|
+
retainedBefore: cutoff,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async function datasetCoverageForRange(ctx, siteId, dataset, from, to) {
|
|
1378
|
+
if (dataset === "siteMap") {
|
|
1379
|
+
return siteMapDatasetCoverage(ctx, siteId, from, to);
|
|
1380
|
+
}
|
|
1381
|
+
// Vitals have no legacy fallback source; the rollup either covers the range
|
|
1382
|
+
// or the coverage states say how much of it exists.
|
|
1383
|
+
if (dataset === "vitals") {
|
|
1384
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1385
|
+
datasets: ["vitalRollups"],
|
|
1386
|
+
complete: false,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
const sourceOptions = dataset === "goals"
|
|
1390
|
+
? [["goalRollups"], ["goalCompletions"]]
|
|
1391
|
+
: dataset === "funnels"
|
|
1392
|
+
? [["funnelRollups"], ["funnelSteps"]]
|
|
1393
|
+
: dataset === "affiliates"
|
|
1394
|
+
? [["affiliateRollups"], ["affiliateStats", "sessions"]]
|
|
1395
|
+
: [[dataset]];
|
|
1396
|
+
const options = (await Promise.all(sourceOptions.map(async (sources) => {
|
|
1397
|
+
const rollupDataset = sources[0];
|
|
1398
|
+
const isRollup = rollupDataset.endsWith("Rollups");
|
|
1399
|
+
const complete = isRollup
|
|
1400
|
+
? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
|
|
1401
|
+
: false;
|
|
1402
|
+
if (isRollup && !complete)
|
|
1403
|
+
return null;
|
|
1404
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1405
|
+
datasets: sources,
|
|
1406
|
+
complete,
|
|
1407
|
+
generation: complete ? 1 : null,
|
|
1408
|
+
});
|
|
1409
|
+
}))).filter((option) => option !== null);
|
|
1410
|
+
return options.sort((left, right) => coverageRank(right.state) - coverageRank(left.state))[0];
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* The site map's coverage without reading its rows: it starts at the site's
|
|
1414
|
+
* `siteMapSince` and ends where retention cut it. The report itself may still
|
|
1415
|
+
* narrow the window when the range exceeds its row budget.
|
|
1416
|
+
*/
|
|
1417
|
+
async function siteMapDatasetCoverage(ctx, siteId, from, to) {
|
|
1418
|
+
const control = await ctx.db
|
|
1419
|
+
.query("analyticsCoverage")
|
|
1420
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1421
|
+
.unique();
|
|
1422
|
+
const since = control?.siteMapSince ?? null;
|
|
1423
|
+
const retainedBefore = control?.retained.find((item) => item.dataset === siteMapCoverageDataset)
|
|
1424
|
+
?.before ?? null;
|
|
1425
|
+
const unavailable = since === null ||
|
|
1426
|
+
to < since ||
|
|
1427
|
+
(retainedBefore !== null && to < retainedBefore);
|
|
1428
|
+
const state = unavailable
|
|
1429
|
+
? "unavailable"
|
|
1430
|
+
: from < Math.max(since, retainedBefore ?? 0)
|
|
1431
|
+
? "partial"
|
|
1432
|
+
: "complete";
|
|
1433
|
+
return {
|
|
1434
|
+
state,
|
|
1435
|
+
generation: 1,
|
|
1436
|
+
availableFrom: since,
|
|
1437
|
+
availableThrough: since === null ? null : to,
|
|
1438
|
+
retainedBefore,
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
async function sourceSetRange(ctx, siteId, sources) {
|
|
1442
|
+
const ranges = (await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))).filter((range) => range !== null);
|
|
1443
|
+
if (ranges.length === 0)
|
|
1444
|
+
return null;
|
|
1445
|
+
return {
|
|
1446
|
+
from: Math.min(...ranges.map((range) => range.from)),
|
|
1447
|
+
through: Math.max(...ranges.map((range) => range.through)),
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
async function sourceRange(ctx, siteId, source) {
|
|
1451
|
+
if (source === "overviewHour" || source === "overviewDay") {
|
|
1452
|
+
const granularity = source === "overviewHour" ? "hour" : "day";
|
|
1453
|
+
const [first, last] = await Promise.all([
|
|
1454
|
+
ctx.db
|
|
1455
|
+
.query("aggregateBuckets")
|
|
1456
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity))
|
|
1457
|
+
.order("asc")
|
|
1458
|
+
.take(1),
|
|
1459
|
+
ctx.db
|
|
1460
|
+
.query("aggregateBuckets")
|
|
1461
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity))
|
|
1462
|
+
.order("desc")
|
|
1463
|
+
.take(1),
|
|
1464
|
+
]);
|
|
1465
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1466
|
+
}
|
|
1467
|
+
if (source === "events") {
|
|
1468
|
+
const [first, last] = await Promise.all([
|
|
1469
|
+
ctx.db
|
|
1470
|
+
.query("eventBatches")
|
|
1471
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1472
|
+
.order("asc")
|
|
1473
|
+
.take(1),
|
|
1474
|
+
ctx.db
|
|
1475
|
+
.query("eventBatches")
|
|
1476
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1477
|
+
.order("desc")
|
|
1478
|
+
.take(1),
|
|
1479
|
+
]);
|
|
1480
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1481
|
+
}
|
|
1482
|
+
if (source === "sessions") {
|
|
1483
|
+
const [first, last] = await Promise.all([
|
|
1484
|
+
ctx.db
|
|
1485
|
+
.query("sessions")
|
|
1486
|
+
.withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
|
|
1487
|
+
.order("asc")
|
|
1488
|
+
.take(1),
|
|
1489
|
+
ctx.db
|
|
1490
|
+
.query("sessions")
|
|
1491
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
|
|
1492
|
+
.order("desc")
|
|
1493
|
+
.take(1),
|
|
1494
|
+
]);
|
|
1495
|
+
return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
|
|
1496
|
+
}
|
|
1497
|
+
if (source === "conversions") {
|
|
1498
|
+
const [first, last] = await Promise.all([
|
|
1499
|
+
ctx.db
|
|
1500
|
+
.query("conversions")
|
|
1501
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1502
|
+
.order("asc")
|
|
1503
|
+
.take(1),
|
|
1504
|
+
ctx.db
|
|
1505
|
+
.query("conversions")
|
|
1506
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1507
|
+
.order("desc")
|
|
1508
|
+
.take(1),
|
|
1509
|
+
]);
|
|
1510
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1511
|
+
}
|
|
1512
|
+
if (source === "goalCompletions") {
|
|
1513
|
+
const [first, last] = await Promise.all([
|
|
1514
|
+
ctx.db
|
|
1515
|
+
.query("goalCompletions")
|
|
1516
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1517
|
+
.order("asc")
|
|
1518
|
+
.take(1),
|
|
1519
|
+
ctx.db
|
|
1520
|
+
.query("goalCompletions")
|
|
1521
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1522
|
+
.order("desc")
|
|
1523
|
+
.take(1),
|
|
1524
|
+
]);
|
|
1525
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1526
|
+
}
|
|
1527
|
+
const table = source === "funnelSteps"
|
|
1528
|
+
? "funnelStepBuckets"
|
|
1529
|
+
: source === "affiliateStats"
|
|
1530
|
+
? "affiliateDailyStats"
|
|
1531
|
+
: source === "goalRollups"
|
|
1532
|
+
? "goalDailyRollups"
|
|
1533
|
+
: source === "funnelRollups"
|
|
1534
|
+
? "funnelDailyRollups"
|
|
1535
|
+
: source === "vitalRollups"
|
|
1536
|
+
? "vitalRollups"
|
|
1537
|
+
: "affiliateDailyRollups";
|
|
1538
|
+
const [first, last] = await Promise.all([
|
|
1539
|
+
ctx.db
|
|
1540
|
+
.query(table)
|
|
1541
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1542
|
+
.order("asc")
|
|
1543
|
+
.take(1),
|
|
1544
|
+
ctx.db
|
|
1545
|
+
.query(table)
|
|
1546
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1547
|
+
.order("desc")
|
|
1548
|
+
.take(1),
|
|
1549
|
+
]);
|
|
1550
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1551
|
+
}
|
|
1552
|
+
function bounds(from, through) {
|
|
1553
|
+
return from === undefined || through === undefined ? null : { from, through };
|
|
1554
|
+
}
|
|
1555
|
+
function coverageRank(state) {
|
|
1556
|
+
if (state === "complete")
|
|
1557
|
+
return 3;
|
|
1558
|
+
if (state === "retained")
|
|
1559
|
+
return 2;
|
|
1560
|
+
if (state === "partial")
|
|
1561
|
+
return 1;
|
|
1562
|
+
return 0;
|
|
1563
|
+
}
|
|
596
1564
|
function emptyTotals() {
|
|
597
1565
|
return {
|
|
598
1566
|
events: 0,
|
|
@@ -619,20 +1587,38 @@ function addMetrics(target, source) {
|
|
|
619
1587
|
}
|
|
620
1588
|
function validateSiteIds(siteIds) {
|
|
621
1589
|
const unique = [...new Set(siteIds)];
|
|
622
|
-
if (unique.length < 1 ||
|
|
1590
|
+
if (unique.length < 1 ||
|
|
1591
|
+
unique.length > MAX_REPORT_SITES ||
|
|
1592
|
+
unique.length !== siteIds.length) {
|
|
623
1593
|
fail("INVALID_ARGUMENT", `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`);
|
|
624
1594
|
}
|
|
625
1595
|
return unique;
|
|
626
1596
|
}
|
|
627
1597
|
function validateRange(from, to, maxDays) {
|
|
628
|
-
if (!Number.
|
|
629
|
-
!Number.
|
|
1598
|
+
if (!Number.isSafeInteger(from) ||
|
|
1599
|
+
!Number.isSafeInteger(to) ||
|
|
630
1600
|
from < 0 ||
|
|
631
|
-
to
|
|
632
|
-
to - from
|
|
633
|
-
fail("INVALID_ARGUMENT", `range must
|
|
1601
|
+
to < from ||
|
|
1602
|
+
to - from >= maxDays * DAY_MS) {
|
|
1603
|
+
fail("INVALID_ARGUMENT", `range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`);
|
|
634
1604
|
}
|
|
635
|
-
return { from
|
|
1605
|
+
return { from, to };
|
|
1606
|
+
}
|
|
1607
|
+
function isAlignedRange(from, to, intervalMs) {
|
|
1608
|
+
return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
|
|
1609
|
+
}
|
|
1610
|
+
function requireAlignedRange(from, to, intervalMs, report) {
|
|
1611
|
+
if (!isAlignedRange(from, to, intervalMs)) {
|
|
1612
|
+
fail("REPORT_INCOMPLETE", `${report} requires complete inclusive UTC bucket boundaries`, { from, to, intervalMs, timezone: "UTC" });
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
function rangeSemantics(from, to) {
|
|
1616
|
+
return {
|
|
1617
|
+
from,
|
|
1618
|
+
to,
|
|
1619
|
+
boundary: "inclusive",
|
|
1620
|
+
timezone: "UTC",
|
|
1621
|
+
};
|
|
636
1622
|
}
|
|
637
1623
|
function parseKeysetPagination(options, scope) {
|
|
638
1624
|
validatePagination(options);
|
|
@@ -714,9 +1700,7 @@ function parseJourneyPagination(options, scope) {
|
|
|
714
1700
|
function keysetPaginationResult(rows, pagination, cursorFor) {
|
|
715
1701
|
const page = rows.slice(0, pagination.numItems);
|
|
716
1702
|
const isDone = rows.length <= pagination.numItems;
|
|
717
|
-
const next = page.length > 0
|
|
718
|
-
? cursorFor(page[page.length - 1])
|
|
719
|
-
: pagination.cursor;
|
|
1703
|
+
const next = page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
|
|
720
1704
|
return {
|
|
721
1705
|
page,
|
|
722
1706
|
isDone,
|
|
@@ -760,7 +1744,9 @@ function journeyPaginationResult(rows, pagination) {
|
|
|
760
1744
|
};
|
|
761
1745
|
}
|
|
762
1746
|
function validatePagination(options) {
|
|
763
|
-
if (!Number.isSafeInteger(options.numItems) ||
|
|
1747
|
+
if (!Number.isSafeInteger(options.numItems) ||
|
|
1748
|
+
options.numItems < 1 ||
|
|
1749
|
+
options.numItems > 100) {
|
|
764
1750
|
fail("INVALID_ARGUMENT", "pagination numItems must be an integer between 1 and 100");
|
|
765
1751
|
}
|
|
766
1752
|
if (options.maximumRowsRead !== undefined &&
|