@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
package/src/component/reports.ts
CHANGED
|
@@ -3,28 +3,48 @@ import {
|
|
|
3
3
|
paginationResultValidator,
|
|
4
4
|
} from "convex/server";
|
|
5
5
|
import { v } from "convex/values";
|
|
6
|
-
import type { Doc } from "./_generated/dataModel.js";
|
|
6
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
7
|
+
import type { QueryCtx } from "./_generated/server.js";
|
|
7
8
|
import { query } from "./_generated/server.js";
|
|
8
9
|
import {
|
|
9
10
|
AGGREGATE_SHARDS,
|
|
10
11
|
DAY_MS,
|
|
12
|
+
FEATURE_REPORT_BYTE_RESERVE,
|
|
13
|
+
FEATURE_REPORT_DOCUMENT_RESERVE,
|
|
11
14
|
HOUR_MS,
|
|
12
15
|
HOURLY_AGGREGATE_SHARDS,
|
|
13
16
|
MAX_AFFILIATES_PER_SITE,
|
|
14
17
|
MAX_FUNNELS_PER_SITE,
|
|
18
|
+
MAX_FEATURE_ROLLUP_DAYS,
|
|
19
|
+
MAX_FEATURE_ROLLUP_ROWS,
|
|
15
20
|
MAX_GOALS_PER_SITE,
|
|
16
21
|
MAX_JOURNEY_EVENTS,
|
|
17
22
|
MAX_LIVE_VISITORS,
|
|
18
23
|
MAX_OVERVIEW_AGGREGATE_ROWS,
|
|
24
|
+
MAX_REPORT_BUCKETS,
|
|
19
25
|
MAX_REPORT_RANGE_DAYS,
|
|
20
26
|
MAX_REPORT_SITES,
|
|
27
|
+
MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
28
|
+
MAX_SITE_MAP_ROWS,
|
|
29
|
+
MAX_VITALS_REPORT_ROWS,
|
|
30
|
+
REPORT_ROLLUP_SHARDS,
|
|
31
|
+
ROUTE_TRANSITION_SLOTS,
|
|
32
|
+
SITE_MAP_OTHER_ROUTE,
|
|
33
|
+
VITALS_REPORT_PAGE_LIMIT,
|
|
21
34
|
} from "./constants.js";
|
|
35
|
+
import {
|
|
36
|
+
createVisitorSketch,
|
|
37
|
+
estimateVisitors,
|
|
38
|
+
unionVisitorSketches,
|
|
39
|
+
type VisitorSketch,
|
|
40
|
+
} from "./cardinality.js";
|
|
22
41
|
import { fail } from "./errors.js";
|
|
23
42
|
import {
|
|
24
43
|
compareJourneyEvents,
|
|
25
44
|
hydrateEventContexts,
|
|
26
45
|
type JourneyCursor,
|
|
27
46
|
loadSessionJourneyEvents,
|
|
47
|
+
loadVisitorJourneyEvents,
|
|
28
48
|
type RawJourneyEvent,
|
|
29
49
|
} from "./eventStore.js";
|
|
30
50
|
import { isPlainRecord } from "./guards.js";
|
|
@@ -34,7 +54,17 @@ import {
|
|
|
34
54
|
funnelFieldsValidator,
|
|
35
55
|
goalFieldsValidator,
|
|
36
56
|
sessionFieldsValidator,
|
|
57
|
+
vitalMetricValidator,
|
|
37
58
|
} from "./validators.js";
|
|
59
|
+
import { resolveVisitorIdentities } from "./visitors.js";
|
|
60
|
+
import {
|
|
61
|
+
mergeVitalHistograms,
|
|
62
|
+
VITAL_ALL,
|
|
63
|
+
VITAL_METRICS,
|
|
64
|
+
vitalHistogramPercentile,
|
|
65
|
+
vitalRatingCounts,
|
|
66
|
+
type VitalMetric,
|
|
67
|
+
} from "./vitals.js";
|
|
38
68
|
|
|
39
69
|
const sessionDocumentValidator = sessionFieldsValidator.extend({
|
|
40
70
|
_id: v.id("sessions"),
|
|
@@ -64,6 +94,8 @@ const liveSessionDocumentValidator = v.object({
|
|
|
64
94
|
device: v.string(),
|
|
65
95
|
pageviewCount: v.number(),
|
|
66
96
|
eventCount: v.number(),
|
|
97
|
+
previousPath: v.optional(v.string()),
|
|
98
|
+
movedAt: v.optional(v.number()),
|
|
67
99
|
});
|
|
68
100
|
const conversionDocumentValidator = v.object({
|
|
69
101
|
_id: v.id("conversions"),
|
|
@@ -95,12 +127,38 @@ const metricTotalsValidator = v.object({
|
|
|
95
127
|
conversions: v.number(),
|
|
96
128
|
revenueCents: v.number(),
|
|
97
129
|
});
|
|
98
|
-
const timePointValidator = metricTotalsValidator.extend({
|
|
130
|
+
const timePointValidator = metricTotalsValidator.extend({
|
|
131
|
+
timestamp: v.number(),
|
|
132
|
+
});
|
|
99
133
|
const topItemValidator = v.object({
|
|
100
134
|
value: v.string(),
|
|
101
135
|
count: v.number(),
|
|
102
136
|
revenueCents: v.number(),
|
|
103
137
|
});
|
|
138
|
+
const coverageStateValidator = v.union(
|
|
139
|
+
v.literal("complete"),
|
|
140
|
+
v.literal("partial"),
|
|
141
|
+
v.literal("retained"),
|
|
142
|
+
v.literal("unavailable"),
|
|
143
|
+
);
|
|
144
|
+
const resultCoverageValidator = v.object({
|
|
145
|
+
state: coverageStateValidator,
|
|
146
|
+
generation: v.union(v.number(), v.null()),
|
|
147
|
+
availableFrom: v.union(v.number(), v.null()),
|
|
148
|
+
availableThrough: v.union(v.number(), v.null()),
|
|
149
|
+
retainedBefore: v.union(v.number(), v.null()),
|
|
150
|
+
});
|
|
151
|
+
const rangeSemanticsValidator = v.object({
|
|
152
|
+
from: v.number(),
|
|
153
|
+
to: v.number(),
|
|
154
|
+
boundary: v.literal("inclusive"),
|
|
155
|
+
timezone: v.literal("UTC"),
|
|
156
|
+
});
|
|
157
|
+
const reportMetadataValidator = v.object({
|
|
158
|
+
basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
|
|
159
|
+
coverage: resultCoverageValidator,
|
|
160
|
+
range: rangeSemanticsValidator,
|
|
161
|
+
});
|
|
104
162
|
|
|
105
163
|
export const overview = query({
|
|
106
164
|
args: {
|
|
@@ -117,19 +175,51 @@ export const overview = query({
|
|
|
117
175
|
totals: metricTotalsValidator,
|
|
118
176
|
timeSeries: v.array(timePointValidator),
|
|
119
177
|
topSources: v.array(topItemValidator),
|
|
178
|
+
topCampaigns: v.array(topItemValidator),
|
|
120
179
|
topPages: v.array(topItemValidator),
|
|
121
180
|
topCountries: v.array(topItemValidator),
|
|
122
181
|
topDevices: v.array(topItemValidator),
|
|
123
182
|
topBrowsers: v.array(topItemValidator),
|
|
124
183
|
topEvents: v.array(topItemValidator),
|
|
125
184
|
topGoals: v.array(topItemValidator),
|
|
185
|
+
metadata: v.object({
|
|
186
|
+
visitors: v.object({
|
|
187
|
+
basis: v.union(
|
|
188
|
+
v.literal("estimatedUnique"),
|
|
189
|
+
v.literal("bucketOccurrences"),
|
|
190
|
+
),
|
|
191
|
+
exact: v.boolean(),
|
|
192
|
+
legacyIncomplete: v.boolean(),
|
|
193
|
+
sketchVersion: v.union(v.number(), v.null()),
|
|
194
|
+
precision: v.union(v.number(), v.null()),
|
|
195
|
+
}),
|
|
196
|
+
breakdowns: v.object({
|
|
197
|
+
topSources: v.literal("eventVolume"),
|
|
198
|
+
topCampaigns: v.literal("eventVolume"),
|
|
199
|
+
topPages: v.literal("eventVolume"),
|
|
200
|
+
topCountries: v.literal("eventVolume"),
|
|
201
|
+
topDevices: v.literal("eventVolume"),
|
|
202
|
+
topBrowsers: v.literal("eventVolume"),
|
|
203
|
+
topEvents: v.literal("eventVolume"),
|
|
204
|
+
topGoals: v.literal("goalCompletionVolume"),
|
|
205
|
+
}),
|
|
206
|
+
coverage: resultCoverageValidator,
|
|
207
|
+
range: rangeSemanticsValidator,
|
|
208
|
+
features: v.object({
|
|
209
|
+
sessionReplay: v.literal("unsupported"),
|
|
210
|
+
errorInsights: v.literal("unsupported"),
|
|
211
|
+
outboundLinks: v.literal("eventVolume"),
|
|
212
|
+
botDetection: v.literal("userAgent"),
|
|
213
|
+
}),
|
|
214
|
+
}),
|
|
126
215
|
}),
|
|
127
216
|
handler: async (ctx, args) => {
|
|
128
217
|
const siteIds = validateSiteIds(args.siteIds);
|
|
129
218
|
const currencies = new Set<string>();
|
|
130
219
|
for (const siteId of siteIds) {
|
|
131
220
|
const site = await ctx.db.get("sites", siteId);
|
|
132
|
-
if (!site)
|
|
221
|
+
if (!site)
|
|
222
|
+
fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
|
|
133
223
|
currencies.add(site.currency ?? "USD");
|
|
134
224
|
}
|
|
135
225
|
if (currencies.size !== 1) {
|
|
@@ -138,16 +228,22 @@ export const overview = query({
|
|
|
138
228
|
});
|
|
139
229
|
}
|
|
140
230
|
const currency = [...currencies][0];
|
|
141
|
-
const { from, to } = validateRange(
|
|
231
|
+
const { from, to } = validateRange(
|
|
232
|
+
args.from,
|
|
233
|
+
args.to,
|
|
234
|
+
MAX_REPORT_RANGE_DAYS,
|
|
235
|
+
);
|
|
142
236
|
const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
|
|
143
237
|
if (interval === "hour" && to - from > DAY_MS) {
|
|
144
238
|
fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
|
|
145
239
|
}
|
|
146
240
|
const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
|
|
241
|
+
requireAlignedRange(from, to, intervalMs, `${interval} overview`);
|
|
147
242
|
const firstBucket = Math.floor(from / intervalMs) * intervalMs;
|
|
148
243
|
const lastBucket = Math.floor(to / intervalMs) * intervalMs;
|
|
149
244
|
const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
|
|
150
|
-
const perSiteLimit =
|
|
245
|
+
const perSiteLimit =
|
|
246
|
+
bucketCount *
|
|
151
247
|
(interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
152
248
|
if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
|
|
153
249
|
fail(
|
|
@@ -158,7 +254,20 @@ export const overview = query({
|
|
|
158
254
|
}
|
|
159
255
|
const totals = emptyTotals();
|
|
160
256
|
const points = new Map<number, MetricTotals>();
|
|
161
|
-
const
|
|
257
|
+
const visitorSketch = createVisitorSketch();
|
|
258
|
+
let mergedVisitorSketch = visitorSketch;
|
|
259
|
+
const pointVisitorSketches = new Map<number, VisitorSketch>();
|
|
260
|
+
let visitorsComplete = true;
|
|
261
|
+
const dimensions = new Map<
|
|
262
|
+
string,
|
|
263
|
+
{ value: string; count: number; revenueCents: number }
|
|
264
|
+
>();
|
|
265
|
+
const overviewDataset =
|
|
266
|
+
interval === "hour"
|
|
267
|
+
? ("overviewHour" as const)
|
|
268
|
+
: ("overviewDay" as const);
|
|
269
|
+
|
|
270
|
+
await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
|
|
162
271
|
|
|
163
272
|
for (const siteId of siteIds) {
|
|
164
273
|
const buckets = await ctx.db
|
|
@@ -170,12 +279,29 @@ export const overview = query({
|
|
|
170
279
|
.gte("bucketStart", firstBucket)
|
|
171
280
|
.lte("bucketStart", lastBucket),
|
|
172
281
|
)
|
|
173
|
-
.take(perSiteLimit);
|
|
282
|
+
.take(perSiteLimit + 1);
|
|
283
|
+
failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
|
|
174
284
|
for (const bucket of buckets) {
|
|
175
285
|
addMetrics(totals, bucket);
|
|
176
286
|
const point = points.get(bucket.bucketStart) ?? emptyTotals();
|
|
177
287
|
addMetrics(point, bucket);
|
|
178
288
|
points.set(bucket.bucketStart, point);
|
|
289
|
+
if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
|
|
290
|
+
mergedVisitorSketch = unionVisitorSketches(
|
|
291
|
+
mergedVisitorSketch,
|
|
292
|
+
bucket.visitorSketch,
|
|
293
|
+
);
|
|
294
|
+
pointVisitorSketches.set(
|
|
295
|
+
bucket.bucketStart,
|
|
296
|
+
unionVisitorSketches(
|
|
297
|
+
pointVisitorSketches.get(bucket.bucketStart) ??
|
|
298
|
+
createVisitorSketch(bucket.visitorSketch.precision),
|
|
299
|
+
bucket.visitorSketch,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
} else if (bucket.events > 0) {
|
|
303
|
+
visitorsComplete = false;
|
|
304
|
+
}
|
|
179
305
|
for (const dimension of bucket.dimensions) {
|
|
180
306
|
const key = `${dimension.type}:${dimension.value}`;
|
|
181
307
|
const current = dimensions.get(key) ?? {
|
|
@@ -194,8 +320,25 @@ export const overview = query({
|
|
|
194
320
|
[...dimensions.entries()]
|
|
195
321
|
.filter(([key]) => key.startsWith(`${type}:`))
|
|
196
322
|
.map(([, value]) => value)
|
|
197
|
-
.sort(
|
|
323
|
+
.sort(
|
|
324
|
+
(left, right) =>
|
|
325
|
+
right.count - left.count || left.value.localeCompare(right.value),
|
|
326
|
+
)
|
|
198
327
|
.slice(0, 10);
|
|
328
|
+
if (visitorsComplete) {
|
|
329
|
+
totals.visitors = estimateVisitors(mergedVisitorSketch);
|
|
330
|
+
for (const [timestamp, point] of points) {
|
|
331
|
+
point.visitors = estimateVisitors(
|
|
332
|
+
pointVisitorSketches.get(timestamp) ?? createVisitorSketch(),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const coverage = await coverageForRange(ctx, siteIds, from, to, {
|
|
337
|
+
datasets: [overviewDataset],
|
|
338
|
+
complete: visitorsComplete,
|
|
339
|
+
});
|
|
340
|
+
const visitorBasis: "estimatedUnique" | "bucketOccurrences" =
|
|
341
|
+
visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
|
|
199
342
|
return {
|
|
200
343
|
from,
|
|
201
344
|
to,
|
|
@@ -206,12 +349,40 @@ export const overview = query({
|
|
|
206
349
|
.sort(([left], [right]) => left - right)
|
|
207
350
|
.map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
|
|
208
351
|
topSources: top("source"),
|
|
352
|
+
topCampaigns: top("campaign"),
|
|
209
353
|
topPages: top("page"),
|
|
210
354
|
topCountries: top("country"),
|
|
211
355
|
topDevices: top("device"),
|
|
212
356
|
topBrowsers: top("browser"),
|
|
213
357
|
topEvents: top("event"),
|
|
214
358
|
topGoals: top("goal"),
|
|
359
|
+
metadata: {
|
|
360
|
+
visitors: {
|
|
361
|
+
basis: visitorBasis,
|
|
362
|
+
exact: false,
|
|
363
|
+
legacyIncomplete: !visitorsComplete,
|
|
364
|
+
sketchVersion: visitorsComplete ? 1 : null,
|
|
365
|
+
precision: visitorsComplete ? 6 : null,
|
|
366
|
+
},
|
|
367
|
+
breakdowns: {
|
|
368
|
+
topSources: "eventVolume" as const,
|
|
369
|
+
topCampaigns: "eventVolume" as const,
|
|
370
|
+
topPages: "eventVolume" as const,
|
|
371
|
+
topCountries: "eventVolume" as const,
|
|
372
|
+
topDevices: "eventVolume" as const,
|
|
373
|
+
topBrowsers: "eventVolume" as const,
|
|
374
|
+
topEvents: "eventVolume" as const,
|
|
375
|
+
topGoals: "goalCompletionVolume" as const,
|
|
376
|
+
},
|
|
377
|
+
coverage,
|
|
378
|
+
range: rangeSemantics(from, to),
|
|
379
|
+
features: {
|
|
380
|
+
sessionReplay: "unsupported" as const,
|
|
381
|
+
errorInsights: "unsupported" as const,
|
|
382
|
+
outboundLinks: "eventVolume" as const,
|
|
383
|
+
botDetection: "userAgent" as const,
|
|
384
|
+
},
|
|
385
|
+
},
|
|
215
386
|
};
|
|
216
387
|
},
|
|
217
388
|
});
|
|
@@ -226,8 +397,15 @@ export const liveVisitors = query({
|
|
|
226
397
|
handler: async (ctx, args) => {
|
|
227
398
|
const siteIds = validateSiteIds(args.siteIds);
|
|
228
399
|
const limit = args.limit ?? 100;
|
|
229
|
-
if (
|
|
230
|
-
|
|
400
|
+
if (
|
|
401
|
+
!Number.isSafeInteger(limit) ||
|
|
402
|
+
limit < 1 ||
|
|
403
|
+
limit > MAX_LIVE_VISITORS
|
|
404
|
+
) {
|
|
405
|
+
fail(
|
|
406
|
+
"INVALID_ARGUMENT",
|
|
407
|
+
`limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`,
|
|
408
|
+
);
|
|
231
409
|
}
|
|
232
410
|
if (!Number.isFinite(args.now) || args.now < 0) {
|
|
233
411
|
fail("INVALID_ARGUMENT", "now must be a positive timestamp");
|
|
@@ -267,10 +445,311 @@ export const liveVisitors = query({
|
|
|
267
445
|
device: row.device,
|
|
268
446
|
pageviewCount: row.pageviewCount,
|
|
269
447
|
eventCount: row.eventCount,
|
|
448
|
+
...(row.previousPath ? { previousPath: row.previousPath } : {}),
|
|
449
|
+
...(row.movedAt !== undefined ? { movedAt: row.movedAt } : {}),
|
|
270
450
|
}));
|
|
271
451
|
},
|
|
272
452
|
});
|
|
273
453
|
|
|
454
|
+
const siteMapRouteValidator = v.object({
|
|
455
|
+
route: v.string(),
|
|
456
|
+
pageviews: v.number(),
|
|
457
|
+
visitors: v.number(),
|
|
458
|
+
entries: v.number(),
|
|
459
|
+
exits: v.number(),
|
|
460
|
+
bounces: v.number(),
|
|
461
|
+
transitionsIn: v.number(),
|
|
462
|
+
transitionsOut: v.number(),
|
|
463
|
+
dwellMs: v.number(),
|
|
464
|
+
dwellSamples: v.number(),
|
|
465
|
+
daily: v.array(v.object({ bucketStart: v.number(), pageviews: v.number() })),
|
|
466
|
+
});
|
|
467
|
+
const siteMapTransitionValidator = v.object({
|
|
468
|
+
from: v.string(),
|
|
469
|
+
to: v.string(),
|
|
470
|
+
count: v.number(),
|
|
471
|
+
});
|
|
472
|
+
const siteMapCoverageDataset = "siteMapRollups";
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Every route the sites served in the range and the transitions between
|
|
476
|
+
* them, read from the daily route rollups newest day first. Exits and
|
|
477
|
+
* bounces are derived rather than counted: a session enters once, leaves
|
|
478
|
+
* once, and every later pageview is a transition, so per route
|
|
479
|
+
* `exits = entries + transitionsIn - transitionsOut` and
|
|
480
|
+
* `bounces = entries - continued` hold without a session-end hook. The
|
|
481
|
+
* report never falls back to raw telemetry; days it could not cover are
|
|
482
|
+
* declared through `coverage` and `window`.
|
|
483
|
+
*/
|
|
484
|
+
export const siteMap = query({
|
|
485
|
+
args: {
|
|
486
|
+
siteIds: v.array(v.id("sites")),
|
|
487
|
+
from: v.number(),
|
|
488
|
+
to: v.number(),
|
|
489
|
+
},
|
|
490
|
+
returns: v.object({
|
|
491
|
+
from: v.number(),
|
|
492
|
+
to: v.number(),
|
|
493
|
+
window: v.union(v.object({ from: v.number(), to: v.number() }), v.null()),
|
|
494
|
+
routes: v.array(siteMapRouteValidator),
|
|
495
|
+
transitions: v.array(siteMapTransitionValidator),
|
|
496
|
+
truncated: v.object({ routes: v.boolean(), transitions: v.boolean() }),
|
|
497
|
+
metadata: reportMetadataValidator.extend({
|
|
498
|
+
visitors: v.object({
|
|
499
|
+
basis: v.literal("estimatedUnique"),
|
|
500
|
+
exact: v.boolean(),
|
|
501
|
+
}),
|
|
502
|
+
limits: v.object({
|
|
503
|
+
routesPerDay: v.number(),
|
|
504
|
+
transitionSlots: v.number(),
|
|
505
|
+
rows: v.number(),
|
|
506
|
+
}),
|
|
507
|
+
}),
|
|
508
|
+
}),
|
|
509
|
+
handler: async (ctx, args) => {
|
|
510
|
+
const siteIds = validateSiteIds(args.siteIds);
|
|
511
|
+
validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
|
|
512
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "site map");
|
|
513
|
+
const controls = await Promise.all(
|
|
514
|
+
siteIds.map((siteId) =>
|
|
515
|
+
ctx.db
|
|
516
|
+
.query("analyticsCoverage")
|
|
517
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
518
|
+
.unique(),
|
|
519
|
+
),
|
|
520
|
+
);
|
|
521
|
+
const since = controls.every(
|
|
522
|
+
(control) => control?.siteMapSince !== undefined,
|
|
523
|
+
)
|
|
524
|
+
? Math.max(...controls.map((control) => control!.siteMapSince!))
|
|
525
|
+
: null;
|
|
526
|
+
const retainedBefore = controls.reduce<number | null>((latest, control) => {
|
|
527
|
+
const before = control?.retained.find(
|
|
528
|
+
(item) => item.dataset === siteMapCoverageDataset,
|
|
529
|
+
)?.before;
|
|
530
|
+
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
531
|
+
}, null);
|
|
532
|
+
const metadata = (state: "complete" | "partial" | "unavailable") => ({
|
|
533
|
+
basis: "dailyRollup" as const,
|
|
534
|
+
coverage: {
|
|
535
|
+
state,
|
|
536
|
+
generation: 1 as const,
|
|
537
|
+
availableFrom: since,
|
|
538
|
+
availableThrough: since === null ? null : args.to,
|
|
539
|
+
retainedBefore,
|
|
540
|
+
},
|
|
541
|
+
range: rangeSemantics(args.from, args.to),
|
|
542
|
+
visitors: { basis: "estimatedUnique" as const, exact: false },
|
|
543
|
+
limits: {
|
|
544
|
+
routesPerDay: MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
545
|
+
transitionSlots: ROUTE_TRANSITION_SLOTS,
|
|
546
|
+
rows: MAX_SITE_MAP_ROWS,
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
if (
|
|
550
|
+
since === null ||
|
|
551
|
+
args.to < since ||
|
|
552
|
+
(retainedBefore !== null && args.to < retainedBefore)
|
|
553
|
+
) {
|
|
554
|
+
return {
|
|
555
|
+
from: args.from,
|
|
556
|
+
to: args.to,
|
|
557
|
+
window: null,
|
|
558
|
+
routes: [],
|
|
559
|
+
transitions: [],
|
|
560
|
+
truncated: { routes: false, transitions: false },
|
|
561
|
+
metadata: metadata("unavailable"),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
const readFrom = Math.max(
|
|
565
|
+
args.from,
|
|
566
|
+
Math.floor(since / DAY_MS) * DAY_MS,
|
|
567
|
+
retainedBefore ?? 0,
|
|
568
|
+
);
|
|
569
|
+
let budget = MAX_SITE_MAP_ROWS;
|
|
570
|
+
let completeFrom = readFrom;
|
|
571
|
+
const rows: Doc<"routeDailyRollups">[] = [];
|
|
572
|
+
for (const siteId of siteIds) {
|
|
573
|
+
const read = await readRouteRollups(
|
|
574
|
+
ctx,
|
|
575
|
+
siteId,
|
|
576
|
+
readFrom,
|
|
577
|
+
args.to,
|
|
578
|
+
budget,
|
|
579
|
+
);
|
|
580
|
+
budget -= read.rows.length;
|
|
581
|
+
completeFrom = Math.max(completeFrom, read.completeFrom);
|
|
582
|
+
rows.push(...read.rows);
|
|
583
|
+
}
|
|
584
|
+
const windowFrom = Math.max(
|
|
585
|
+
args.from,
|
|
586
|
+
completeFrom,
|
|
587
|
+
since,
|
|
588
|
+
retainedBefore ?? 0,
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
type RouteTotals = {
|
|
592
|
+
pageviews: number;
|
|
593
|
+
entries: number;
|
|
594
|
+
continued: number;
|
|
595
|
+
dwellMs: number;
|
|
596
|
+
dwellSamples: number;
|
|
597
|
+
transitionsIn: number;
|
|
598
|
+
transitionsOut: number;
|
|
599
|
+
sketch: VisitorSketch;
|
|
600
|
+
daily: Map<number, number>;
|
|
601
|
+
};
|
|
602
|
+
const routes = new Map<string, RouteTotals>();
|
|
603
|
+
const totalsFor = (route: string) => {
|
|
604
|
+
let current = routes.get(route);
|
|
605
|
+
if (!current) {
|
|
606
|
+
current = {
|
|
607
|
+
pageviews: 0,
|
|
608
|
+
entries: 0,
|
|
609
|
+
continued: 0,
|
|
610
|
+
dwellMs: 0,
|
|
611
|
+
dwellSamples: 0,
|
|
612
|
+
transitionsIn: 0,
|
|
613
|
+
transitionsOut: 0,
|
|
614
|
+
sketch: createVisitorSketch(),
|
|
615
|
+
daily: new Map<number, number>(),
|
|
616
|
+
};
|
|
617
|
+
routes.set(route, current);
|
|
618
|
+
}
|
|
619
|
+
return current;
|
|
620
|
+
};
|
|
621
|
+
const edges = new Map<
|
|
622
|
+
string,
|
|
623
|
+
{ from: string; to: string; count: number }
|
|
624
|
+
>();
|
|
625
|
+
for (const row of rows) {
|
|
626
|
+
if (row.bucketStart < completeFrom) continue;
|
|
627
|
+
const current = totalsFor(row.route);
|
|
628
|
+
current.pageviews += row.pageviews;
|
|
629
|
+
current.entries += row.entries;
|
|
630
|
+
current.continued += row.continued;
|
|
631
|
+
current.dwellMs += row.dwellMs;
|
|
632
|
+
current.dwellSamples += row.dwellSamples;
|
|
633
|
+
current.sketch = unionVisitorSketches(current.sketch, row.visitorSketch);
|
|
634
|
+
current.daily.set(
|
|
635
|
+
row.bucketStart,
|
|
636
|
+
(current.daily.get(row.bucketStart) ?? 0) + row.pageviews,
|
|
637
|
+
);
|
|
638
|
+
for (const target of row.outgoing) {
|
|
639
|
+
const key = `${row.route}\u0000${target.route}`;
|
|
640
|
+
const edge = edges.get(key) ?? {
|
|
641
|
+
from: row.route,
|
|
642
|
+
to: target.route,
|
|
643
|
+
count: 0,
|
|
644
|
+
};
|
|
645
|
+
edge.count += target.count;
|
|
646
|
+
edges.set(key, edge);
|
|
647
|
+
current.transitionsOut += target.count;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// A target without a row of its own in this window — folded past the
|
|
651
|
+
// daily cap, or already retained — counts under "(other)".
|
|
652
|
+
const transitions = new Map<
|
|
653
|
+
string,
|
|
654
|
+
{ from: string; to: string; count: number }
|
|
655
|
+
>();
|
|
656
|
+
for (const edge of edges.values()) {
|
|
657
|
+
const to = routes.has(edge.to) ? edge.to : SITE_MAP_OTHER_ROUTE;
|
|
658
|
+
const key = `${edge.from}\u0000${to}`;
|
|
659
|
+
const merged = transitions.get(key) ?? { from: edge.from, to, count: 0 };
|
|
660
|
+
merged.count += edge.count;
|
|
661
|
+
transitions.set(key, merged);
|
|
662
|
+
totalsFor(to).transitionsIn += edge.count;
|
|
663
|
+
}
|
|
664
|
+
const routeRows = [...routes.entries()]
|
|
665
|
+
.map(([route, totals]) => ({
|
|
666
|
+
route,
|
|
667
|
+
pageviews: totals.pageviews,
|
|
668
|
+
visitors: estimateVisitors(totals.sketch),
|
|
669
|
+
entries: totals.entries,
|
|
670
|
+
exits: Math.max(
|
|
671
|
+
0,
|
|
672
|
+
totals.entries + totals.transitionsIn - totals.transitionsOut,
|
|
673
|
+
),
|
|
674
|
+
bounces: Math.max(0, totals.entries - totals.continued),
|
|
675
|
+
transitionsIn: totals.transitionsIn,
|
|
676
|
+
transitionsOut: totals.transitionsOut,
|
|
677
|
+
dwellMs: totals.dwellMs,
|
|
678
|
+
dwellSamples: totals.dwellSamples,
|
|
679
|
+
daily: [...totals.daily.entries()]
|
|
680
|
+
.sort(([left], [right]) => left - right)
|
|
681
|
+
.map(([bucketStart, pageviews]) => ({ bucketStart, pageviews })),
|
|
682
|
+
}))
|
|
683
|
+
.sort(
|
|
684
|
+
(left, right) =>
|
|
685
|
+
right.pageviews - left.pageviews ||
|
|
686
|
+
left.route.localeCompare(right.route),
|
|
687
|
+
);
|
|
688
|
+
const transitionRows = [...transitions.values()].sort(
|
|
689
|
+
(left, right) =>
|
|
690
|
+
right.count - left.count ||
|
|
691
|
+
left.from.localeCompare(right.from) ||
|
|
692
|
+
left.to.localeCompare(right.to),
|
|
693
|
+
);
|
|
694
|
+
return {
|
|
695
|
+
from: args.from,
|
|
696
|
+
to: args.to,
|
|
697
|
+
window: completeFrom > args.to ? null : { from: windowFrom, to: args.to },
|
|
698
|
+
routes: routeRows,
|
|
699
|
+
transitions: transitionRows,
|
|
700
|
+
truncated: {
|
|
701
|
+
routes: routes.has(SITE_MAP_OTHER_ROUTE),
|
|
702
|
+
transitions: transitionRows.some(
|
|
703
|
+
(transition) =>
|
|
704
|
+
transition.from === SITE_MAP_OTHER_ROUTE ||
|
|
705
|
+
transition.to === SITE_MAP_OTHER_ROUTE,
|
|
706
|
+
),
|
|
707
|
+
},
|
|
708
|
+
metadata: metadata(windowFrom > args.from ? "partial" : "complete"),
|
|
709
|
+
};
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Reads a site's route rollups newest day first until the row budget or the
|
|
715
|
+
* transaction's read headroom runs out, then drops the day it could only
|
|
716
|
+
* read in part so every day it returns is complete.
|
|
717
|
+
*/
|
|
718
|
+
async function readRouteRollups(
|
|
719
|
+
ctx: QueryCtx,
|
|
720
|
+
siteId: Id<"sites">,
|
|
721
|
+
from: number,
|
|
722
|
+
to: number,
|
|
723
|
+
budget: number,
|
|
724
|
+
) {
|
|
725
|
+
const rows: Doc<"routeDailyRollups">[] = [];
|
|
726
|
+
let cutAt: number | null = null;
|
|
727
|
+
const query = ctx.db
|
|
728
|
+
.query("routeDailyRollups")
|
|
729
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
730
|
+
range
|
|
731
|
+
.eq("siteId", siteId)
|
|
732
|
+
.gte("bucketStart", from)
|
|
733
|
+
.lte("bucketStart", to),
|
|
734
|
+
)
|
|
735
|
+
.order("desc");
|
|
736
|
+
for await (const row of query) {
|
|
737
|
+
if (
|
|
738
|
+
rows.length >= budget ||
|
|
739
|
+
(rows.length % 256 === 255 && !(await hasFeatureReadHeadroom(ctx)))
|
|
740
|
+
) {
|
|
741
|
+
cutAt = row.bucketStart;
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
rows.push(row);
|
|
745
|
+
}
|
|
746
|
+
if (cutAt === null) return { rows, completeFrom: from };
|
|
747
|
+
while (rows.length > 0 && rows[rows.length - 1]!.bucketStart === cutAt) {
|
|
748
|
+
rows.pop();
|
|
749
|
+
}
|
|
750
|
+
return { rows, completeFrom: cutAt + DAY_MS };
|
|
751
|
+
}
|
|
752
|
+
|
|
274
753
|
export const listSessions = query({
|
|
275
754
|
args: {
|
|
276
755
|
siteId: v.id("sites"),
|
|
@@ -281,6 +760,7 @@ export const listSessions = query({
|
|
|
281
760
|
returns: paginationResultValidator(sessionDocumentValidator),
|
|
282
761
|
handler: async (ctx, args) => {
|
|
283
762
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
763
|
+
await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
|
|
284
764
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
285
765
|
kind: "sessions",
|
|
286
766
|
siteId: String(args.siteId),
|
|
@@ -330,7 +810,14 @@ export const listSessions = query({
|
|
|
330
810
|
.take(take)),
|
|
331
811
|
);
|
|
332
812
|
}
|
|
333
|
-
|
|
813
|
+
const publicRows = rows.map(
|
|
814
|
+
({
|
|
815
|
+
geoLookupAttemptedAt: _attemptedAt,
|
|
816
|
+
lastPageviewAt: _lastPageviewAt,
|
|
817
|
+
...row
|
|
818
|
+
}) => row,
|
|
819
|
+
);
|
|
820
|
+
return keysetPaginationResult(publicRows, pagination, (row) => ({
|
|
334
821
|
timestamp: row.lastSeenAt,
|
|
335
822
|
creationTime: row._creationTime,
|
|
336
823
|
}));
|
|
@@ -348,6 +835,7 @@ export const sessionJourney = query({
|
|
|
348
835
|
returns: paginationResultValidator(eventDocumentValidator),
|
|
349
836
|
handler: async (ctx, args) => {
|
|
350
837
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
838
|
+
await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
|
|
351
839
|
const pagination = parseJourneyPagination(args.paginationOpts, {
|
|
352
840
|
kind: "events",
|
|
353
841
|
siteId: String(args.siteId),
|
|
@@ -378,6 +866,7 @@ export const listConversions = query({
|
|
|
378
866
|
returns: paginationResultValidator(conversionDocumentValidator),
|
|
379
867
|
handler: async (ctx, args) => {
|
|
380
868
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
869
|
+
await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
|
|
381
870
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
382
871
|
kind: "conversions",
|
|
383
872
|
siteId: String(args.siteId),
|
|
@@ -446,54 +935,39 @@ export const visitorJourney = query({
|
|
|
446
935
|
handler: async (ctx, args) => {
|
|
447
936
|
const siteIds = validateSiteIds(args.siteIds);
|
|
448
937
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
938
|
+
await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
|
|
449
939
|
const limit = args.limit ?? 200;
|
|
450
|
-
if (
|
|
451
|
-
|
|
940
|
+
if (
|
|
941
|
+
!Number.isSafeInteger(limit) ||
|
|
942
|
+
limit < 1 ||
|
|
943
|
+
limit > MAX_JOURNEY_EVENTS
|
|
944
|
+
) {
|
|
945
|
+
fail(
|
|
946
|
+
"INVALID_ARGUMENT",
|
|
947
|
+
`limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`,
|
|
948
|
+
);
|
|
452
949
|
}
|
|
453
|
-
const sessionGroups = await Promise.all(
|
|
454
|
-
siteIds.map((siteId) =>
|
|
455
|
-
ctx.db
|
|
456
|
-
.query("sessions")
|
|
457
|
-
.withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) =>
|
|
458
|
-
range
|
|
459
|
-
.eq("siteId", siteId)
|
|
460
|
-
.eq("visitorId", args.visitorId)
|
|
461
|
-
.gte("lastSeenAt", args.from),
|
|
462
|
-
)
|
|
463
|
-
.order("asc")
|
|
464
|
-
.take(MAX_JOURNEY_EVENTS + 1),
|
|
465
|
-
),
|
|
466
|
-
);
|
|
467
|
-
const sessions = sessionGroups
|
|
468
|
-
.flat()
|
|
469
|
-
.filter((session) => session.startedAt <= args.to)
|
|
470
|
-
.sort((left, right) => left.startedAt - right.startedAt)
|
|
471
|
-
.slice(0, MAX_JOURNEY_EVENTS);
|
|
472
950
|
const rows: RawJourneyEvent[] = [];
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
951
|
+
for (const siteId of siteIds) {
|
|
952
|
+
for (const visitorId of await resolveVisitorIdentities(
|
|
953
|
+
ctx,
|
|
954
|
+
siteId,
|
|
955
|
+
args.visitorId,
|
|
956
|
+
)) {
|
|
957
|
+
const cutoff =
|
|
958
|
+
rows.length >= limit ? rows[limit - 1]!.timestamp : args.to;
|
|
959
|
+
rows.push(
|
|
960
|
+
...(await loadVisitorJourneyEvents(ctx, {
|
|
961
|
+
siteId,
|
|
962
|
+
visitorId,
|
|
963
|
+
from: args.from,
|
|
964
|
+
to: cutoff,
|
|
965
|
+
resultLimit: limit,
|
|
966
|
+
})),
|
|
485
967
|
);
|
|
968
|
+
rows.sort(compareJourneyEvents);
|
|
969
|
+
if (rows.length > limit) rows.length = limit;
|
|
486
970
|
}
|
|
487
|
-
rows.push(...await loadSessionJourneyEvents(ctx, {
|
|
488
|
-
siteId: session.siteId,
|
|
489
|
-
sessionId: session.sessionId,
|
|
490
|
-
from: args.from,
|
|
491
|
-
to: Math.min(args.to, cutoff),
|
|
492
|
-
cursor: null,
|
|
493
|
-
resultLimit: resultIsFull ? limit : limit - rows.length,
|
|
494
|
-
}));
|
|
495
|
-
rows.sort(compareJourneyEvents);
|
|
496
|
-
if (rows.length > limit) rows.length = limit;
|
|
497
971
|
}
|
|
498
972
|
return hydrateEventContexts(ctx, rows);
|
|
499
973
|
},
|
|
@@ -511,36 +985,90 @@ export const goalsReport = query({
|
|
|
511
985
|
returns: v.object({
|
|
512
986
|
goals: v.array(goalReportItemValidator),
|
|
513
987
|
truncated: v.boolean(),
|
|
988
|
+
metadata: reportMetadataValidator,
|
|
514
989
|
}),
|
|
515
990
|
handler: async (ctx, args) => {
|
|
516
991
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
517
|
-
const
|
|
992
|
+
const goalRows = await ctx.db
|
|
518
993
|
.query("goals")
|
|
519
994
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
520
|
-
.take(MAX_GOALS_PER_SITE);
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
995
|
+
.take(MAX_GOALS_PER_SITE + 1);
|
|
996
|
+
failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
|
|
997
|
+
const goals = goalRows;
|
|
998
|
+
const totals = new Map<
|
|
999
|
+
string,
|
|
1000
|
+
{ completions: number; valueCents: number }
|
|
1001
|
+
>();
|
|
1002
|
+
const useRollups = await dailyRollupsAvailable(
|
|
1003
|
+
ctx,
|
|
1004
|
+
args.siteId,
|
|
1005
|
+
args.from,
|
|
1006
|
+
args.to,
|
|
1007
|
+
"goalRollups",
|
|
1008
|
+
);
|
|
1009
|
+
const rollups = useRollups
|
|
1010
|
+
? await loadGoalDailyRollups(
|
|
1011
|
+
ctx,
|
|
1012
|
+
args.siteId,
|
|
1013
|
+
goals.map((goal) => goal._id),
|
|
1014
|
+
args.from,
|
|
1015
|
+
args.to,
|
|
1016
|
+
)
|
|
1017
|
+
: null;
|
|
1018
|
+
if (rollups) {
|
|
1019
|
+
for (const row of rollups) {
|
|
1020
|
+
const key = String(row.goalId);
|
|
1021
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
1022
|
+
total.completions += row.completions;
|
|
1023
|
+
total.valueCents += row.valueCents;
|
|
1024
|
+
totals.set(key, total);
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
await assertSourceAvailable(
|
|
1028
|
+
ctx,
|
|
1029
|
+
[args.siteId],
|
|
1030
|
+
["goalCompletions"],
|
|
1031
|
+
args.from,
|
|
1032
|
+
);
|
|
1033
|
+
const completions = await ctx.db
|
|
1034
|
+
.query("goalCompletions")
|
|
1035
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
1036
|
+
range
|
|
1037
|
+
.eq("siteId", args.siteId)
|
|
1038
|
+
.gte("timestamp", args.from)
|
|
1039
|
+
.lte("timestamp", args.to),
|
|
1040
|
+
)
|
|
1041
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
1042
|
+
failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
|
|
1043
|
+
for (const completion of completions) {
|
|
1044
|
+
const key = String(completion.goalId);
|
|
1045
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
1046
|
+
total.completions += 1;
|
|
1047
|
+
total.valueCents += completion.valueCents;
|
|
1048
|
+
totals.set(key, total);
|
|
1049
|
+
}
|
|
537
1050
|
}
|
|
538
1051
|
return {
|
|
539
1052
|
goals: goals.map((goal) => ({
|
|
540
1053
|
...goal,
|
|
541
1054
|
...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
|
|
542
1055
|
})),
|
|
543
|
-
truncated:
|
|
1056
|
+
truncated: false,
|
|
1057
|
+
metadata: {
|
|
1058
|
+
basis: rollups ? ("dailyRollup" as const) : ("boundedLegacy" as const),
|
|
1059
|
+
coverage: await coverageForRange(
|
|
1060
|
+
ctx,
|
|
1061
|
+
[args.siteId],
|
|
1062
|
+
args.from,
|
|
1063
|
+
args.to,
|
|
1064
|
+
{
|
|
1065
|
+
datasets: [rollups ? "goalRollups" : "goalCompletions"],
|
|
1066
|
+
complete: true,
|
|
1067
|
+
generation: rollups ? 1 : null,
|
|
1068
|
+
},
|
|
1069
|
+
),
|
|
1070
|
+
range: rangeSemantics(args.from, args.to),
|
|
1071
|
+
},
|
|
544
1072
|
};
|
|
545
1073
|
},
|
|
546
1074
|
});
|
|
@@ -549,9 +1077,15 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
|
|
|
549
1077
|
_id: v.id("funnels"),
|
|
550
1078
|
_creationTime: v.number(),
|
|
551
1079
|
stepsReport: v.array(
|
|
552
|
-
v.object({
|
|
1080
|
+
v.object({
|
|
1081
|
+
step: v.number(),
|
|
1082
|
+
name: v.string(),
|
|
1083
|
+
entrants: v.number(),
|
|
1084
|
+
completions: v.number(),
|
|
1085
|
+
}),
|
|
553
1086
|
),
|
|
554
1087
|
truncated: v.boolean(),
|
|
1088
|
+
metadata: reportMetadataValidator,
|
|
555
1089
|
});
|
|
556
1090
|
|
|
557
1091
|
export const funnelsReport = query({
|
|
@@ -559,29 +1093,74 @@ export const funnelsReport = query({
|
|
|
559
1093
|
returns: v.array(funnelReportItemValidator),
|
|
560
1094
|
handler: async (ctx, args) => {
|
|
561
1095
|
validateRange(args.from, args.to, 90);
|
|
562
|
-
|
|
1096
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
|
|
1097
|
+
const funnelRows = await ctx.db
|
|
563
1098
|
.query("funnels")
|
|
564
1099
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
565
|
-
.take(MAX_FUNNELS_PER_SITE);
|
|
1100
|
+
.take(MAX_FUNNELS_PER_SITE + 1);
|
|
1101
|
+
failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
|
|
1102
|
+
const funnels = funnelRows;
|
|
1103
|
+
const useRollups = await dailyRollupsAvailable(
|
|
1104
|
+
ctx,
|
|
1105
|
+
args.siteId,
|
|
1106
|
+
args.from,
|
|
1107
|
+
args.to,
|
|
1108
|
+
"funnelRollups",
|
|
1109
|
+
);
|
|
1110
|
+
const rollups = useRollups
|
|
1111
|
+
? await loadFunnelDailyRollups(
|
|
1112
|
+
ctx,
|
|
1113
|
+
args.siteId,
|
|
1114
|
+
funnels.map((funnel) => funnel._id),
|
|
1115
|
+
args.from,
|
|
1116
|
+
args.to,
|
|
1117
|
+
)
|
|
1118
|
+
: null;
|
|
1119
|
+
if (!rollups) {
|
|
1120
|
+
await assertSourceAvailable(
|
|
1121
|
+
ctx,
|
|
1122
|
+
[args.siteId],
|
|
1123
|
+
["funnelSteps"],
|
|
1124
|
+
args.from,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
const reportCoverage = await coverageForRange(
|
|
1128
|
+
ctx,
|
|
1129
|
+
[args.siteId],
|
|
1130
|
+
args.from,
|
|
1131
|
+
args.to,
|
|
1132
|
+
{
|
|
1133
|
+
datasets: [rollups ? "funnelRollups" : "funnelSteps"],
|
|
1134
|
+
complete: true,
|
|
1135
|
+
generation: rollups ? 1 : null,
|
|
1136
|
+
},
|
|
1137
|
+
);
|
|
566
1138
|
const reports = [];
|
|
567
1139
|
for (const funnel of funnels) {
|
|
568
|
-
const buckets = await ctx.db
|
|
569
|
-
.query("funnelStepBuckets")
|
|
570
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
|
|
571
|
-
range
|
|
572
|
-
.eq("siteId", args.siteId)
|
|
573
|
-
.eq("funnelId", funnel._id)
|
|
574
|
-
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
575
|
-
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
|
|
576
|
-
)
|
|
577
|
-
.take(601);
|
|
578
1140
|
const stepsReport = funnel.steps.map((step, index) => ({
|
|
579
1141
|
step: index,
|
|
580
1142
|
name: step.name,
|
|
581
1143
|
entrants: 0,
|
|
582
1144
|
completions: 0,
|
|
583
1145
|
}));
|
|
584
|
-
|
|
1146
|
+
const buckets = rollups
|
|
1147
|
+
? rollups
|
|
1148
|
+
.filter((row) => row.funnelId === funnel._id)
|
|
1149
|
+
.flatMap((row) => row.steps)
|
|
1150
|
+
: await ctx.db
|
|
1151
|
+
.query("funnelStepBuckets")
|
|
1152
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
|
|
1153
|
+
range
|
|
1154
|
+
.eq("siteId", args.siteId)
|
|
1155
|
+
.eq("funnelId", funnel._id)
|
|
1156
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
1157
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
|
|
1158
|
+
)
|
|
1159
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
1160
|
+
if (!rollups) {
|
|
1161
|
+
failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
|
|
1162
|
+
}
|
|
1163
|
+
for (const bucket of buckets) {
|
|
585
1164
|
const step = stepsReport[bucket.step];
|
|
586
1165
|
if (step) {
|
|
587
1166
|
step.entrants += bucket.entrants;
|
|
@@ -591,7 +1170,14 @@ export const funnelsReport = query({
|
|
|
591
1170
|
reports.push({
|
|
592
1171
|
...funnel,
|
|
593
1172
|
stepsReport,
|
|
594
|
-
truncated:
|
|
1173
|
+
truncated: false,
|
|
1174
|
+
metadata: {
|
|
1175
|
+
basis: rollups
|
|
1176
|
+
? ("dailyRollup" as const)
|
|
1177
|
+
: ("boundedLegacy" as const),
|
|
1178
|
+
coverage: reportCoverage,
|
|
1179
|
+
range: rangeSemantics(args.from, args.to),
|
|
1180
|
+
},
|
|
595
1181
|
});
|
|
596
1182
|
}
|
|
597
1183
|
return reports;
|
|
@@ -613,64 +1199,134 @@ export const affiliatesReport = query({
|
|
|
613
1199
|
returns: v.object({
|
|
614
1200
|
affiliates: v.array(affiliateReportItemValidator),
|
|
615
1201
|
truncated: v.boolean(),
|
|
1202
|
+
metadata: reportMetadataValidator.extend({
|
|
1203
|
+
uniqueVisitors: v.object({
|
|
1204
|
+
basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
|
|
1205
|
+
exact: v.boolean(),
|
|
1206
|
+
}),
|
|
1207
|
+
}),
|
|
616
1208
|
}),
|
|
617
1209
|
handler: async (ctx, args) => {
|
|
618
1210
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
1211
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
|
|
619
1212
|
const affiliateRows = await ctx.db
|
|
620
1213
|
.query("affiliates")
|
|
621
1214
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
622
1215
|
.order("desc")
|
|
623
1216
|
.take(MAX_AFFILIATES_PER_SITE + 1);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1217
|
+
failIfIncomplete(
|
|
1218
|
+
affiliateRows,
|
|
1219
|
+
MAX_AFFILIATES_PER_SITE,
|
|
1220
|
+
"affiliate definitions",
|
|
1221
|
+
);
|
|
1222
|
+
const affiliates = affiliateRows;
|
|
1223
|
+
const useRollups = await dailyRollupsAvailable(
|
|
1224
|
+
ctx,
|
|
1225
|
+
args.siteId,
|
|
1226
|
+
args.from,
|
|
1227
|
+
args.to,
|
|
1228
|
+
"affiliateRollups",
|
|
1229
|
+
);
|
|
1230
|
+
const rollups = useRollups
|
|
1231
|
+
? await loadAffiliateDailyRollups(
|
|
1232
|
+
ctx,
|
|
1233
|
+
args.siteId,
|
|
1234
|
+
affiliates.map((affiliate) => affiliate._id),
|
|
1235
|
+
args.from,
|
|
1236
|
+
args.to,
|
|
1237
|
+
)
|
|
1238
|
+
: null;
|
|
1239
|
+
const visits = new Map<
|
|
1240
|
+
string,
|
|
1241
|
+
{
|
|
1242
|
+
visits: number;
|
|
1243
|
+
visitors: Set<string>;
|
|
1244
|
+
sketch: VisitorSketch;
|
|
1245
|
+
}
|
|
1246
|
+
>();
|
|
1247
|
+
const totals = new Map<
|
|
1248
|
+
string,
|
|
1249
|
+
{
|
|
1250
|
+
conversions: number;
|
|
1251
|
+
revenueCents: number;
|
|
1252
|
+
commissionCents: number;
|
|
1253
|
+
}
|
|
1254
|
+
>();
|
|
1255
|
+
if (rollups) {
|
|
1256
|
+
for (const row of rollups) {
|
|
1257
|
+
const key = String(row.affiliateId);
|
|
1258
|
+
const traffic = visits.get(key) ?? {
|
|
1259
|
+
visits: 0,
|
|
1260
|
+
visitors: new Set<string>(),
|
|
1261
|
+
sketch: createVisitorSketch(),
|
|
1262
|
+
};
|
|
1263
|
+
traffic.visits += row.visits;
|
|
1264
|
+
traffic.sketch = unionVisitorSketches(
|
|
1265
|
+
traffic.sketch,
|
|
1266
|
+
row.visitorSketch,
|
|
1267
|
+
);
|
|
1268
|
+
visits.set(key, traffic);
|
|
1269
|
+
const total = totals.get(key) ?? {
|
|
1270
|
+
conversions: 0,
|
|
1271
|
+
revenueCents: 0,
|
|
1272
|
+
commissionCents: 0,
|
|
1273
|
+
};
|
|
1274
|
+
total.conversions += row.conversions;
|
|
1275
|
+
total.revenueCents += row.revenueCents;
|
|
1276
|
+
total.commissionCents += row.commissionCents;
|
|
1277
|
+
totals.set(key, total);
|
|
1278
|
+
}
|
|
1279
|
+
} else {
|
|
1280
|
+
await assertSourceAvailable(
|
|
1281
|
+
ctx,
|
|
1282
|
+
[args.siteId],
|
|
1283
|
+
["affiliateStats", "sessions"],
|
|
1284
|
+
args.from,
|
|
1285
|
+
);
|
|
1286
|
+
const stats = await ctx.db
|
|
1287
|
+
.query("affiliateDailyStats")
|
|
1288
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
1289
|
+
range
|
|
1290
|
+
.eq("siteId", args.siteId)
|
|
1291
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
1292
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
|
|
1293
|
+
)
|
|
1294
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
1295
|
+
const sessions = await ctx.db
|
|
1296
|
+
.query("sessions")
|
|
1297
|
+
.withIndex("by_siteId_and_startedAt", (range) =>
|
|
1298
|
+
range
|
|
1299
|
+
.eq("siteId", args.siteId)
|
|
1300
|
+
.gte("startedAt", args.from)
|
|
1301
|
+
.lte("startedAt", args.to),
|
|
1302
|
+
)
|
|
1303
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
1304
|
+
failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
|
|
1305
|
+
failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
|
|
1306
|
+
for (const session of sessions) {
|
|
1307
|
+
if (!session.affiliateId) continue;
|
|
1308
|
+
const key = String(session.affiliateId);
|
|
1309
|
+
const total = visits.get(key) ?? {
|
|
1310
|
+
visits: 0,
|
|
1311
|
+
visitors: new Set<string>(),
|
|
1312
|
+
sketch: createVisitorSketch(),
|
|
1313
|
+
};
|
|
1314
|
+
total.visits += 1;
|
|
1315
|
+
total.visitors.add(session.visitorId);
|
|
1316
|
+
visits.set(key, total);
|
|
1317
|
+
}
|
|
1318
|
+
for (const row of stats) {
|
|
1319
|
+
const key = String(row.affiliateId);
|
|
1320
|
+
const total = totals.get(key) ?? {
|
|
1321
|
+
conversions: 0,
|
|
1322
|
+
revenueCents: 0,
|
|
1323
|
+
commissionCents: 0,
|
|
1324
|
+
};
|
|
1325
|
+
total.conversions += row.conversions;
|
|
1326
|
+
total.revenueCents += row.revenueCents;
|
|
1327
|
+
total.commissionCents += row.commissionCents;
|
|
1328
|
+
totals.set(key, total);
|
|
1329
|
+
}
|
|
674
1330
|
}
|
|
675
1331
|
return {
|
|
676
1332
|
affiliates: affiliates.map((affiliate) => {
|
|
@@ -684,17 +1340,799 @@ export const affiliatesReport = query({
|
|
|
684
1340
|
...affiliate,
|
|
685
1341
|
...performance,
|
|
686
1342
|
visits: traffic?.visits ?? 0,
|
|
687
|
-
uniqueVisitors:
|
|
1343
|
+
uniqueVisitors: rollups
|
|
1344
|
+
? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
|
|
1345
|
+
: (traffic?.visitors.size ?? 0),
|
|
688
1346
|
};
|
|
689
1347
|
}),
|
|
690
|
-
truncated:
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1348
|
+
truncated: false,
|
|
1349
|
+
metadata: {
|
|
1350
|
+
basis: rollups ? ("dailyRollup" as const) : ("boundedLegacy" as const),
|
|
1351
|
+
coverage: await coverageForRange(
|
|
1352
|
+
ctx,
|
|
1353
|
+
[args.siteId],
|
|
1354
|
+
args.from,
|
|
1355
|
+
args.to,
|
|
1356
|
+
{
|
|
1357
|
+
datasets: rollups
|
|
1358
|
+
? ["affiliateRollups"]
|
|
1359
|
+
: ["affiliateStats", "sessions"],
|
|
1360
|
+
complete: true,
|
|
1361
|
+
generation: rollups ? 1 : null,
|
|
1362
|
+
},
|
|
1363
|
+
),
|
|
1364
|
+
range: rangeSemantics(args.from, args.to),
|
|
1365
|
+
uniqueVisitors: {
|
|
1366
|
+
basis: rollups
|
|
1367
|
+
? ("estimatedUnique" as const)
|
|
1368
|
+
: ("exactLegacy" as const),
|
|
1369
|
+
exact: !rollups,
|
|
1370
|
+
},
|
|
1371
|
+
},
|
|
1372
|
+
};
|
|
1373
|
+
},
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
const vitalStatsValidator = v.object({
|
|
1377
|
+
count: v.number(),
|
|
1378
|
+
p75: v.number(),
|
|
1379
|
+
average: v.number(),
|
|
1380
|
+
good: v.number(),
|
|
1381
|
+
needsImprovement: v.number(),
|
|
1382
|
+
poor: v.number(),
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
export const vitalsReport = query({
|
|
1386
|
+
args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
|
|
1387
|
+
returns: v.object({
|
|
1388
|
+
metrics: v.array(
|
|
1389
|
+
vitalStatsValidator.extend({
|
|
1390
|
+
metric: vitalMetricValidator,
|
|
1391
|
+
devices: v.array(vitalStatsValidator.extend({ device: v.string() })),
|
|
1392
|
+
pages: v.array(vitalStatsValidator.extend({ page: v.string() })),
|
|
1393
|
+
timeSeries: v.array(
|
|
1394
|
+
v.object({
|
|
1395
|
+
timestamp: v.number(),
|
|
1396
|
+
count: v.number(),
|
|
1397
|
+
p75: v.number(),
|
|
1398
|
+
}),
|
|
1399
|
+
),
|
|
1400
|
+
}),
|
|
1401
|
+
),
|
|
1402
|
+
truncated: v.boolean(),
|
|
1403
|
+
metadata: reportMetadataValidator,
|
|
1404
|
+
}),
|
|
1405
|
+
handler: async (ctx, args) => {
|
|
1406
|
+
validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
|
|
1407
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "vitals report");
|
|
1408
|
+
await assertSourceAvailable(
|
|
1409
|
+
ctx,
|
|
1410
|
+
[args.siteId],
|
|
1411
|
+
["vitalRollups"],
|
|
1412
|
+
args.from,
|
|
1413
|
+
);
|
|
1414
|
+
const rows = await ctx.db
|
|
1415
|
+
.query("vitalRollups")
|
|
1416
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
1417
|
+
range
|
|
1418
|
+
.eq("siteId", args.siteId)
|
|
1419
|
+
.gte("bucketStart", args.from)
|
|
1420
|
+
.lte("bucketStart", args.to),
|
|
1421
|
+
)
|
|
1422
|
+
.take(MAX_VITALS_REPORT_ROWS + 1);
|
|
1423
|
+
failIfIncomplete(rows, MAX_VITALS_REPORT_ROWS, "vital rollups");
|
|
1424
|
+
|
|
1425
|
+
type Accumulated = { count: number; sum: number; histogram: number[] };
|
|
1426
|
+
const fold = <Key>(
|
|
1427
|
+
groups: Map<VitalMetric, Map<Key, Accumulated>>,
|
|
1428
|
+
metric: VitalMetric,
|
|
1429
|
+
key: Key,
|
|
1430
|
+
entry: { count: number; sum: number; histogram: number[] },
|
|
1431
|
+
) => {
|
|
1432
|
+
const group = groups.get(metric) ?? new Map<Key, Accumulated>();
|
|
1433
|
+
const current = group.get(key) ?? { count: 0, sum: 0, histogram: [] };
|
|
1434
|
+
current.count += entry.count;
|
|
1435
|
+
current.sum += entry.sum;
|
|
1436
|
+
current.histogram = mergeVitalHistograms(
|
|
1437
|
+
current.histogram,
|
|
1438
|
+
entry.histogram,
|
|
1439
|
+
);
|
|
1440
|
+
group.set(key, current);
|
|
1441
|
+
groups.set(metric, group);
|
|
1442
|
+
};
|
|
1443
|
+
const byDevice = new Map<VitalMetric, Map<string, Accumulated>>();
|
|
1444
|
+
const byPage = new Map<VitalMetric, Map<string, Accumulated>>();
|
|
1445
|
+
const byDay = new Map<VitalMetric, Map<number, Accumulated>>();
|
|
1446
|
+
for (const row of rows) {
|
|
1447
|
+
for (const entry of row.metrics) {
|
|
1448
|
+
if (row.page === VITAL_ALL) {
|
|
1449
|
+
// A device row carries the complete signal once, so overall totals
|
|
1450
|
+
// and the time series derive from this family alone.
|
|
1451
|
+
fold(byDevice, entry.metric, row.device, entry);
|
|
1452
|
+
fold(byDay, entry.metric, row.bucketStart, entry);
|
|
1453
|
+
} else {
|
|
1454
|
+
fold(byPage, entry.metric, row.page, entry);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
const stats = (metric: VitalMetric, accumulated: Accumulated) => ({
|
|
1459
|
+
count: accumulated.count,
|
|
1460
|
+
p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
|
|
1461
|
+
average:
|
|
1462
|
+
accumulated.count > 0
|
|
1463
|
+
? Math.round(accumulated.sum / accumulated.count)
|
|
1464
|
+
: 0,
|
|
1465
|
+
...vitalRatingCounts(accumulated.histogram, metric),
|
|
1466
|
+
});
|
|
1467
|
+
const metrics = VITAL_METRICS.map((metric) => {
|
|
1468
|
+
const overall: Accumulated = { count: 0, sum: 0, histogram: [] };
|
|
1469
|
+
for (const accumulated of byDevice.get(metric)?.values() ?? []) {
|
|
1470
|
+
overall.count += accumulated.count;
|
|
1471
|
+
overall.sum += accumulated.sum;
|
|
1472
|
+
overall.histogram = mergeVitalHistograms(
|
|
1473
|
+
overall.histogram,
|
|
1474
|
+
accumulated.histogram,
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
return {
|
|
1478
|
+
metric,
|
|
1479
|
+
...stats(metric, overall),
|
|
1480
|
+
devices: [...(byDevice.get(metric) ?? [])]
|
|
1481
|
+
.map(([device, accumulated]) => ({
|
|
1482
|
+
device,
|
|
1483
|
+
...stats(metric, accumulated),
|
|
1484
|
+
}))
|
|
1485
|
+
.sort((left, right) => right.count - left.count),
|
|
1486
|
+
pages: [...(byPage.get(metric) ?? [])]
|
|
1487
|
+
.map(([page, accumulated]) => ({
|
|
1488
|
+
page,
|
|
1489
|
+
...stats(metric, accumulated),
|
|
1490
|
+
}))
|
|
1491
|
+
.sort(
|
|
1492
|
+
(left, right) =>
|
|
1493
|
+
right.count - left.count || left.page.localeCompare(right.page),
|
|
1494
|
+
)
|
|
1495
|
+
.slice(0, VITALS_REPORT_PAGE_LIMIT),
|
|
1496
|
+
timeSeries: [...(byDay.get(metric) ?? [])]
|
|
1497
|
+
.sort(([left], [right]) => left - right)
|
|
1498
|
+
.map(([timestamp, accumulated]) => ({
|
|
1499
|
+
timestamp,
|
|
1500
|
+
count: accumulated.count,
|
|
1501
|
+
p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
|
|
1502
|
+
})),
|
|
1503
|
+
};
|
|
1504
|
+
});
|
|
1505
|
+
return {
|
|
1506
|
+
metrics,
|
|
1507
|
+
truncated: false,
|
|
1508
|
+
metadata: {
|
|
1509
|
+
basis: "dailyRollup" as const,
|
|
1510
|
+
coverage: await coverageForRange(
|
|
1511
|
+
ctx,
|
|
1512
|
+
[args.siteId],
|
|
1513
|
+
args.from,
|
|
1514
|
+
args.to,
|
|
1515
|
+
{ datasets: ["vitalRollups"], complete: true },
|
|
1516
|
+
),
|
|
1517
|
+
range: rangeSemantics(args.from, args.to),
|
|
1518
|
+
},
|
|
1519
|
+
};
|
|
1520
|
+
},
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
const coverageDatasetValidator = v.union(
|
|
1524
|
+
v.literal("overviewHour"),
|
|
1525
|
+
v.literal("overviewDay"),
|
|
1526
|
+
v.literal("events"),
|
|
1527
|
+
v.literal("sessions"),
|
|
1528
|
+
v.literal("conversions"),
|
|
1529
|
+
v.literal("goals"),
|
|
1530
|
+
v.literal("funnels"),
|
|
1531
|
+
v.literal("affiliates"),
|
|
1532
|
+
v.literal("vitals"),
|
|
1533
|
+
v.literal("siteMap"),
|
|
1534
|
+
);
|
|
1535
|
+
|
|
1536
|
+
export const dataCoverage = query({
|
|
1537
|
+
args: {
|
|
1538
|
+
siteId: v.id("sites"),
|
|
1539
|
+
from: v.number(),
|
|
1540
|
+
to: v.number(),
|
|
1541
|
+
},
|
|
1542
|
+
returns: v.object({
|
|
1543
|
+
siteId: v.id("sites"),
|
|
1544
|
+
from: v.number(),
|
|
1545
|
+
to: v.number(),
|
|
1546
|
+
datasets: v.array(
|
|
1547
|
+
v.object({
|
|
1548
|
+
dataset: coverageDatasetValidator,
|
|
1549
|
+
coverage: resultCoverageValidator,
|
|
1550
|
+
}),
|
|
1551
|
+
),
|
|
1552
|
+
}),
|
|
1553
|
+
handler: async (ctx, args) => {
|
|
1554
|
+
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
1555
|
+
const datasets = [
|
|
1556
|
+
"overviewHour",
|
|
1557
|
+
"overviewDay",
|
|
1558
|
+
"events",
|
|
1559
|
+
"sessions",
|
|
1560
|
+
"conversions",
|
|
1561
|
+
"goals",
|
|
1562
|
+
"funnels",
|
|
1563
|
+
"affiliates",
|
|
1564
|
+
"vitals",
|
|
1565
|
+
"siteMap",
|
|
1566
|
+
] as const;
|
|
1567
|
+
return {
|
|
1568
|
+
siteId: args.siteId,
|
|
1569
|
+
from: args.from,
|
|
1570
|
+
to: args.to,
|
|
1571
|
+
datasets: await Promise.all(
|
|
1572
|
+
datasets.map(async (dataset) => ({
|
|
1573
|
+
dataset,
|
|
1574
|
+
coverage: await datasetCoverageForRange(
|
|
1575
|
+
ctx,
|
|
1576
|
+
args.siteId,
|
|
1577
|
+
dataset,
|
|
1578
|
+
args.from,
|
|
1579
|
+
args.to,
|
|
1580
|
+
),
|
|
1581
|
+
})),
|
|
1582
|
+
),
|
|
694
1583
|
};
|
|
695
1584
|
},
|
|
696
1585
|
});
|
|
697
1586
|
|
|
1587
|
+
async function dailyRollupsAvailable(
|
|
1588
|
+
ctx: QueryCtx,
|
|
1589
|
+
siteId: Id<"sites">,
|
|
1590
|
+
from: number,
|
|
1591
|
+
to: number,
|
|
1592
|
+
dataset: "goalRollups" | "funnelRollups" | "affiliateRollups",
|
|
1593
|
+
) {
|
|
1594
|
+
if (!isAlignedRange(from, to, DAY_MS)) return false;
|
|
1595
|
+
const coverage = await ctx.db
|
|
1596
|
+
.query("analyticsCoverage")
|
|
1597
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1598
|
+
.unique();
|
|
1599
|
+
if (!coverage || coverage.rollupCompleteFrom > from) {
|
|
1600
|
+
return false;
|
|
1601
|
+
}
|
|
1602
|
+
const retainedBefore = coverage.retained.find(
|
|
1603
|
+
(item) => item.dataset === dataset,
|
|
1604
|
+
)?.before;
|
|
1605
|
+
return retainedBefore === undefined || from >= retainedBefore;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
async function loadGoalDailyRollups(
|
|
1609
|
+
ctx: QueryCtx,
|
|
1610
|
+
siteId: Id<"sites">,
|
|
1611
|
+
goalIds: Id<"goals">[],
|
|
1612
|
+
from: number,
|
|
1613
|
+
to: number,
|
|
1614
|
+
) {
|
|
1615
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1616
|
+
requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
|
|
1617
|
+
const rows: Doc<"goalDailyRollups">[] = [];
|
|
1618
|
+
for (const goalId of goalIds) {
|
|
1619
|
+
const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1620
|
+
const goalRows = await ctx.db
|
|
1621
|
+
.query("goalDailyRollups")
|
|
1622
|
+
.withIndex("by_siteId_and_goalId_and_bucketStart", (range) =>
|
|
1623
|
+
range
|
|
1624
|
+
.eq("siteId", siteId)
|
|
1625
|
+
.eq("goalId", goalId)
|
|
1626
|
+
.gte("bucketStart", from)
|
|
1627
|
+
.lte("bucketStart", to),
|
|
1628
|
+
)
|
|
1629
|
+
.take(perGoalLimit + 1);
|
|
1630
|
+
failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
|
|
1631
|
+
rows.push(...goalRows);
|
|
1632
|
+
await requireFeatureReadHeadroom(ctx, "goal daily rollups");
|
|
1633
|
+
}
|
|
1634
|
+
return rows;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
async function loadFunnelDailyRollups(
|
|
1638
|
+
ctx: QueryCtx,
|
|
1639
|
+
siteId: Id<"sites">,
|
|
1640
|
+
funnelIds: Id<"funnels">[],
|
|
1641
|
+
from: number,
|
|
1642
|
+
to: number,
|
|
1643
|
+
) {
|
|
1644
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1645
|
+
requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
|
|
1646
|
+
const rows: Doc<"funnelDailyRollups">[] = [];
|
|
1647
|
+
for (const funnelId of funnelIds) {
|
|
1648
|
+
const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1649
|
+
const funnelRows = await ctx.db
|
|
1650
|
+
.query("funnelDailyRollups")
|
|
1651
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
|
|
1652
|
+
range
|
|
1653
|
+
.eq("siteId", siteId)
|
|
1654
|
+
.eq("funnelId", funnelId)
|
|
1655
|
+
.gte("bucketStart", from)
|
|
1656
|
+
.lte("bucketStart", to),
|
|
1657
|
+
)
|
|
1658
|
+
.take(perFunnelLimit + 1);
|
|
1659
|
+
failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
|
|
1660
|
+
rows.push(...funnelRows);
|
|
1661
|
+
await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
|
|
1662
|
+
}
|
|
1663
|
+
return rows;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async function loadAffiliateDailyRollups(
|
|
1667
|
+
ctx: QueryCtx,
|
|
1668
|
+
siteId: Id<"sites">,
|
|
1669
|
+
affiliateIds: Id<"affiliates">[],
|
|
1670
|
+
from: number,
|
|
1671
|
+
to: number,
|
|
1672
|
+
) {
|
|
1673
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1674
|
+
requireFeatureRollupBudget(
|
|
1675
|
+
affiliateIds.length,
|
|
1676
|
+
days,
|
|
1677
|
+
"affiliate daily rollups",
|
|
1678
|
+
);
|
|
1679
|
+
const rows: Doc<"affiliateDailyRollups">[] = [];
|
|
1680
|
+
for (const affiliateId of affiliateIds) {
|
|
1681
|
+
const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1682
|
+
const affiliateRows = await ctx.db
|
|
1683
|
+
.query("affiliateDailyRollups")
|
|
1684
|
+
.withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) =>
|
|
1685
|
+
range
|
|
1686
|
+
.eq("siteId", siteId)
|
|
1687
|
+
.eq("affiliateId", affiliateId)
|
|
1688
|
+
.gte("bucketStart", from)
|
|
1689
|
+
.lte("bucketStart", to),
|
|
1690
|
+
)
|
|
1691
|
+
.take(perAffiliateLimit + 1);
|
|
1692
|
+
failIfIncomplete(
|
|
1693
|
+
affiliateRows,
|
|
1694
|
+
perAffiliateLimit,
|
|
1695
|
+
"affiliate daily rollups",
|
|
1696
|
+
);
|
|
1697
|
+
rows.push(...affiliateRows);
|
|
1698
|
+
await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
|
|
1699
|
+
}
|
|
1700
|
+
return rows;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function requireFeatureRollupBudget(
|
|
1704
|
+
entities: number,
|
|
1705
|
+
days: number,
|
|
1706
|
+
dataset: string,
|
|
1707
|
+
) {
|
|
1708
|
+
const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
|
|
1709
|
+
if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
|
|
1710
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
|
|
1711
|
+
dataset,
|
|
1712
|
+
maximumDays: MAX_FEATURE_ROLLUP_DAYS,
|
|
1713
|
+
maximumRows: MAX_FEATURE_ROLLUP_ROWS,
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
async function hasFeatureReadHeadroom(ctx: QueryCtx) {
|
|
1719
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
1720
|
+
return (
|
|
1721
|
+
metrics.bytesRead.remaining >= FEATURE_REPORT_BYTE_RESERVE &&
|
|
1722
|
+
metrics.documentsRead.remaining >= FEATURE_REPORT_DOCUMENT_RESERVE
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
async function requireFeatureReadHeadroom(ctx: QueryCtx, dataset: string) {
|
|
1727
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
1728
|
+
if (
|
|
1729
|
+
metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
|
|
1730
|
+
metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE
|
|
1731
|
+
) {
|
|
1732
|
+
fail(
|
|
1733
|
+
"REPORT_INCOMPLETE",
|
|
1734
|
+
`${dataset} exceeds its byte or document budget`,
|
|
1735
|
+
{
|
|
1736
|
+
dataset,
|
|
1737
|
+
bytesRead: metrics.bytesRead.used,
|
|
1738
|
+
documentsRead: metrics.documentsRead.used,
|
|
1739
|
+
},
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function failIfIncomplete<T>(rows: T[], maximum: number, dataset: string) {
|
|
1745
|
+
if (rows.length > maximum) {
|
|
1746
|
+
fail(
|
|
1747
|
+
"REPORT_INCOMPLETE",
|
|
1748
|
+
`${dataset} exceeds the complete report read budget`,
|
|
1749
|
+
{
|
|
1750
|
+
maximumRows: maximum,
|
|
1751
|
+
dataset,
|
|
1752
|
+
},
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
async function coverageForRange(
|
|
1758
|
+
ctx: QueryCtx,
|
|
1759
|
+
siteIds: Id<"sites">[],
|
|
1760
|
+
from: number,
|
|
1761
|
+
to: number,
|
|
1762
|
+
options: {
|
|
1763
|
+
datasets: CoverageSource[];
|
|
1764
|
+
complete?: boolean;
|
|
1765
|
+
generation?: 1 | null;
|
|
1766
|
+
},
|
|
1767
|
+
) {
|
|
1768
|
+
const controls = await Promise.all(
|
|
1769
|
+
siteIds.map((siteId) =>
|
|
1770
|
+
ctx.db
|
|
1771
|
+
.query("analyticsCoverage")
|
|
1772
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1773
|
+
.unique(),
|
|
1774
|
+
),
|
|
1775
|
+
);
|
|
1776
|
+
const sourceRanges = await Promise.all(
|
|
1777
|
+
siteIds.map((siteId) => sourceSetRange(ctx, siteId, options.datasets)),
|
|
1778
|
+
);
|
|
1779
|
+
const retainedBefore = controls.reduce<number | null>((latest, control) => {
|
|
1780
|
+
const before = control?.retained
|
|
1781
|
+
.filter((item) =>
|
|
1782
|
+
options.datasets.some((dataset) => dataset === item.dataset),
|
|
1783
|
+
)
|
|
1784
|
+
.reduce<number | undefined>(
|
|
1785
|
+
(latest, item) => Math.max(latest ?? 0, item.before),
|
|
1786
|
+
undefined,
|
|
1787
|
+
);
|
|
1788
|
+
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
1789
|
+
}, null);
|
|
1790
|
+
const availableFrom =
|
|
1791
|
+
options.generation === 1
|
|
1792
|
+
? controls.length > 0 && controls.every(Boolean)
|
|
1793
|
+
? Math.max(...controls.map((control) => control!.rollupCompleteFrom))
|
|
1794
|
+
: null
|
|
1795
|
+
: sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1796
|
+
? Math.max(...sourceRanges.map((range) => range!.from))
|
|
1797
|
+
: null;
|
|
1798
|
+
const availableThrough =
|
|
1799
|
+
sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1800
|
+
? Math.min(...sourceRanges.map((range) => range!.through))
|
|
1801
|
+
: null;
|
|
1802
|
+
let state: "complete" | "partial" | "retained" | "unavailable";
|
|
1803
|
+
if (retainedBefore !== null && to < retainedBefore) {
|
|
1804
|
+
state = "unavailable";
|
|
1805
|
+
} else if (retainedBefore !== null && from < retainedBefore) {
|
|
1806
|
+
state = "partial";
|
|
1807
|
+
} else if (
|
|
1808
|
+
options.complete ||
|
|
1809
|
+
(availableFrom !== null &&
|
|
1810
|
+
availableThrough !== null &&
|
|
1811
|
+
from >= availableFrom &&
|
|
1812
|
+
to <= availableThrough)
|
|
1813
|
+
) {
|
|
1814
|
+
state = "complete";
|
|
1815
|
+
} else if (sourceRanges.some(Boolean)) {
|
|
1816
|
+
state = "retained";
|
|
1817
|
+
} else {
|
|
1818
|
+
state = "unavailable";
|
|
1819
|
+
}
|
|
1820
|
+
return {
|
|
1821
|
+
state,
|
|
1822
|
+
generation: options.generation ?? null,
|
|
1823
|
+
availableFrom,
|
|
1824
|
+
availableThrough,
|
|
1825
|
+
retainedBefore,
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
async function assertSourceAvailable(
|
|
1830
|
+
ctx: QueryCtx,
|
|
1831
|
+
siteIds: Id<"sites">[],
|
|
1832
|
+
sources: CoverageSource[],
|
|
1833
|
+
from: number,
|
|
1834
|
+
) {
|
|
1835
|
+
const rows = await Promise.all(
|
|
1836
|
+
siteIds.map((siteId) =>
|
|
1837
|
+
ctx.db
|
|
1838
|
+
.query("analyticsCoverage")
|
|
1839
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1840
|
+
.unique(),
|
|
1841
|
+
),
|
|
1842
|
+
);
|
|
1843
|
+
const cutoff = rows.reduce<number | null>((latest, row) => {
|
|
1844
|
+
for (const retained of row?.retained ?? []) {
|
|
1845
|
+
if (sources.some((source) => source === retained.dataset)) {
|
|
1846
|
+
latest = Math.max(latest ?? 0, retained.before);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
return latest;
|
|
1850
|
+
}, null);
|
|
1851
|
+
if (cutoff !== null && from < cutoff) {
|
|
1852
|
+
fail(
|
|
1853
|
+
"REPORT_INCOMPLETE",
|
|
1854
|
+
"requested report range intersects retained data",
|
|
1855
|
+
{
|
|
1856
|
+
retainedBefore: cutoff,
|
|
1857
|
+
},
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
async function datasetCoverageForRange(
|
|
1863
|
+
ctx: QueryCtx,
|
|
1864
|
+
siteId: Id<"sites">,
|
|
1865
|
+
dataset: PublicCoverageDataset,
|
|
1866
|
+
from: number,
|
|
1867
|
+
to: number,
|
|
1868
|
+
) {
|
|
1869
|
+
if (dataset === "siteMap") {
|
|
1870
|
+
return siteMapDatasetCoverage(ctx, siteId, from, to);
|
|
1871
|
+
}
|
|
1872
|
+
// Vitals have no legacy fallback source; the rollup either covers the range
|
|
1873
|
+
// or the coverage states say how much of it exists.
|
|
1874
|
+
if (dataset === "vitals") {
|
|
1875
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1876
|
+
datasets: ["vitalRollups"],
|
|
1877
|
+
complete: false,
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
const sourceOptions: CoverageSource[][] =
|
|
1881
|
+
dataset === "goals"
|
|
1882
|
+
? [["goalRollups"], ["goalCompletions"]]
|
|
1883
|
+
: dataset === "funnels"
|
|
1884
|
+
? [["funnelRollups"], ["funnelSteps"]]
|
|
1885
|
+
: dataset === "affiliates"
|
|
1886
|
+
? [["affiliateRollups"], ["affiliateStats", "sessions"]]
|
|
1887
|
+
: [[dataset]];
|
|
1888
|
+
const options = (
|
|
1889
|
+
await Promise.all(
|
|
1890
|
+
sourceOptions.map(async (sources) => {
|
|
1891
|
+
const rollupDataset = sources[0] as
|
|
1892
|
+
"goalRollups" | "funnelRollups" | "affiliateRollups";
|
|
1893
|
+
const isRollup = rollupDataset.endsWith("Rollups");
|
|
1894
|
+
const complete = isRollup
|
|
1895
|
+
? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
|
|
1896
|
+
: false;
|
|
1897
|
+
if (isRollup && !complete) return null;
|
|
1898
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1899
|
+
datasets: sources,
|
|
1900
|
+
complete,
|
|
1901
|
+
generation: complete ? 1 : null,
|
|
1902
|
+
});
|
|
1903
|
+
}),
|
|
1904
|
+
)
|
|
1905
|
+
).filter((option): option is NonNullable<typeof option> => option !== null);
|
|
1906
|
+
return options.sort(
|
|
1907
|
+
(left, right) => coverageRank(right.state) - coverageRank(left.state),
|
|
1908
|
+
)[0]!;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* The site map's coverage without reading its rows: it starts at the site's
|
|
1913
|
+
* `siteMapSince` and ends where retention cut it. The report itself may still
|
|
1914
|
+
* narrow the window when the range exceeds its row budget.
|
|
1915
|
+
*/
|
|
1916
|
+
async function siteMapDatasetCoverage(
|
|
1917
|
+
ctx: QueryCtx,
|
|
1918
|
+
siteId: Id<"sites">,
|
|
1919
|
+
from: number,
|
|
1920
|
+
to: number,
|
|
1921
|
+
) {
|
|
1922
|
+
const control = await ctx.db
|
|
1923
|
+
.query("analyticsCoverage")
|
|
1924
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1925
|
+
.unique();
|
|
1926
|
+
const since = control?.siteMapSince ?? null;
|
|
1927
|
+
const retainedBefore =
|
|
1928
|
+
control?.retained.find((item) => item.dataset === siteMapCoverageDataset)
|
|
1929
|
+
?.before ?? null;
|
|
1930
|
+
const unavailable =
|
|
1931
|
+
since === null ||
|
|
1932
|
+
to < since ||
|
|
1933
|
+
(retainedBefore !== null && to < retainedBefore);
|
|
1934
|
+
const state = unavailable
|
|
1935
|
+
? ("unavailable" as const)
|
|
1936
|
+
: from < Math.max(since, retainedBefore ?? 0)
|
|
1937
|
+
? ("partial" as const)
|
|
1938
|
+
: ("complete" as const);
|
|
1939
|
+
return {
|
|
1940
|
+
state,
|
|
1941
|
+
generation: 1 as const,
|
|
1942
|
+
availableFrom: since,
|
|
1943
|
+
availableThrough: since === null ? null : to,
|
|
1944
|
+
retainedBefore,
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
type PublicCoverageDataset =
|
|
1949
|
+
| "overviewHour"
|
|
1950
|
+
| "overviewDay"
|
|
1951
|
+
| "events"
|
|
1952
|
+
| "sessions"
|
|
1953
|
+
| "conversions"
|
|
1954
|
+
| "goals"
|
|
1955
|
+
| "funnels"
|
|
1956
|
+
| "affiliates"
|
|
1957
|
+
| "vitals"
|
|
1958
|
+
| "siteMap";
|
|
1959
|
+
|
|
1960
|
+
type CoverageSource =
|
|
1961
|
+
| "overviewHour"
|
|
1962
|
+
| "overviewDay"
|
|
1963
|
+
| "events"
|
|
1964
|
+
| "sessions"
|
|
1965
|
+
| "conversions"
|
|
1966
|
+
| "goalCompletions"
|
|
1967
|
+
| "funnelSteps"
|
|
1968
|
+
| "affiliateStats"
|
|
1969
|
+
| "goalRollups"
|
|
1970
|
+
| "funnelRollups"
|
|
1971
|
+
| "affiliateRollups"
|
|
1972
|
+
| "vitalRollups";
|
|
1973
|
+
|
|
1974
|
+
type SourceRange = { from: number; through: number };
|
|
1975
|
+
|
|
1976
|
+
async function sourceSetRange(
|
|
1977
|
+
ctx: QueryCtx,
|
|
1978
|
+
siteId: Id<"sites">,
|
|
1979
|
+
sources: CoverageSource[],
|
|
1980
|
+
): Promise<SourceRange | null> {
|
|
1981
|
+
const ranges = (
|
|
1982
|
+
await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))
|
|
1983
|
+
).filter((range): range is SourceRange => range !== null);
|
|
1984
|
+
if (ranges.length === 0) return null;
|
|
1985
|
+
return {
|
|
1986
|
+
from: Math.min(...ranges.map((range) => range.from)),
|
|
1987
|
+
through: Math.max(...ranges.map((range) => range.through)),
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
async function sourceRange(
|
|
1992
|
+
ctx: QueryCtx,
|
|
1993
|
+
siteId: Id<"sites">,
|
|
1994
|
+
source: CoverageSource,
|
|
1995
|
+
): Promise<SourceRange | null> {
|
|
1996
|
+
if (source === "overviewHour" || source === "overviewDay") {
|
|
1997
|
+
const granularity =
|
|
1998
|
+
source === "overviewHour" ? ("hour" as const) : ("day" as const);
|
|
1999
|
+
const [first, last] = await Promise.all([
|
|
2000
|
+
ctx.db
|
|
2001
|
+
.query("aggregateBuckets")
|
|
2002
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
2003
|
+
range.eq("siteId", siteId).eq("granularity", granularity),
|
|
2004
|
+
)
|
|
2005
|
+
.order("asc")
|
|
2006
|
+
.take(1),
|
|
2007
|
+
ctx.db
|
|
2008
|
+
.query("aggregateBuckets")
|
|
2009
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
2010
|
+
range.eq("siteId", siteId).eq("granularity", granularity),
|
|
2011
|
+
)
|
|
2012
|
+
.order("desc")
|
|
2013
|
+
.take(1),
|
|
2014
|
+
]);
|
|
2015
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
2016
|
+
}
|
|
2017
|
+
if (source === "events") {
|
|
2018
|
+
const [first, last] = await Promise.all([
|
|
2019
|
+
ctx.db
|
|
2020
|
+
.query("eventBatches")
|
|
2021
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
2022
|
+
range.eq("siteId", siteId),
|
|
2023
|
+
)
|
|
2024
|
+
.order("asc")
|
|
2025
|
+
.take(1),
|
|
2026
|
+
ctx.db
|
|
2027
|
+
.query("eventBatches")
|
|
2028
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
2029
|
+
range.eq("siteId", siteId),
|
|
2030
|
+
)
|
|
2031
|
+
.order("desc")
|
|
2032
|
+
.take(1),
|
|
2033
|
+
]);
|
|
2034
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
2035
|
+
}
|
|
2036
|
+
if (source === "sessions") {
|
|
2037
|
+
const [first, last] = await Promise.all([
|
|
2038
|
+
ctx.db
|
|
2039
|
+
.query("sessions")
|
|
2040
|
+
.withIndex("by_siteId_and_startedAt", (range) =>
|
|
2041
|
+
range.eq("siteId", siteId),
|
|
2042
|
+
)
|
|
2043
|
+
.order("asc")
|
|
2044
|
+
.take(1),
|
|
2045
|
+
ctx.db
|
|
2046
|
+
.query("sessions")
|
|
2047
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) =>
|
|
2048
|
+
range.eq("siteId", siteId),
|
|
2049
|
+
)
|
|
2050
|
+
.order("desc")
|
|
2051
|
+
.take(1),
|
|
2052
|
+
]);
|
|
2053
|
+
return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
|
|
2054
|
+
}
|
|
2055
|
+
if (source === "conversions") {
|
|
2056
|
+
const [first, last] = await Promise.all([
|
|
2057
|
+
ctx.db
|
|
2058
|
+
.query("conversions")
|
|
2059
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
2060
|
+
range.eq("siteId", siteId),
|
|
2061
|
+
)
|
|
2062
|
+
.order("asc")
|
|
2063
|
+
.take(1),
|
|
2064
|
+
ctx.db
|
|
2065
|
+
.query("conversions")
|
|
2066
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
2067
|
+
range.eq("siteId", siteId),
|
|
2068
|
+
)
|
|
2069
|
+
.order("desc")
|
|
2070
|
+
.take(1),
|
|
2071
|
+
]);
|
|
2072
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
2073
|
+
}
|
|
2074
|
+
if (source === "goalCompletions") {
|
|
2075
|
+
const [first, last] = await Promise.all([
|
|
2076
|
+
ctx.db
|
|
2077
|
+
.query("goalCompletions")
|
|
2078
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
2079
|
+
range.eq("siteId", siteId),
|
|
2080
|
+
)
|
|
2081
|
+
.order("asc")
|
|
2082
|
+
.take(1),
|
|
2083
|
+
ctx.db
|
|
2084
|
+
.query("goalCompletions")
|
|
2085
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
2086
|
+
range.eq("siteId", siteId),
|
|
2087
|
+
)
|
|
2088
|
+
.order("desc")
|
|
2089
|
+
.take(1),
|
|
2090
|
+
]);
|
|
2091
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
2092
|
+
}
|
|
2093
|
+
const table =
|
|
2094
|
+
source === "funnelSteps"
|
|
2095
|
+
? ("funnelStepBuckets" as const)
|
|
2096
|
+
: source === "affiliateStats"
|
|
2097
|
+
? ("affiliateDailyStats" as const)
|
|
2098
|
+
: source === "goalRollups"
|
|
2099
|
+
? ("goalDailyRollups" as const)
|
|
2100
|
+
: source === "funnelRollups"
|
|
2101
|
+
? ("funnelDailyRollups" as const)
|
|
2102
|
+
: source === "vitalRollups"
|
|
2103
|
+
? ("vitalRollups" as const)
|
|
2104
|
+
: ("affiliateDailyRollups" as const);
|
|
2105
|
+
const [first, last] = await Promise.all([
|
|
2106
|
+
ctx.db
|
|
2107
|
+
.query(table)
|
|
2108
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
2109
|
+
range.eq("siteId", siteId),
|
|
2110
|
+
)
|
|
2111
|
+
.order("asc")
|
|
2112
|
+
.take(1),
|
|
2113
|
+
ctx.db
|
|
2114
|
+
.query(table)
|
|
2115
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
2116
|
+
range.eq("siteId", siteId),
|
|
2117
|
+
)
|
|
2118
|
+
.order("desc")
|
|
2119
|
+
.take(1),
|
|
2120
|
+
]);
|
|
2121
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function bounds(from: number | undefined, through: number | undefined) {
|
|
2125
|
+
return from === undefined || through === undefined ? null : { from, through };
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
function coverageRank(
|
|
2129
|
+
state: "complete" | "partial" | "retained" | "unavailable",
|
|
2130
|
+
) {
|
|
2131
|
+
if (state === "complete") return 3;
|
|
2132
|
+
if (state === "retained") return 2;
|
|
2133
|
+
if (state === "partial") return 1;
|
|
2134
|
+
return 0;
|
|
2135
|
+
}
|
|
698
2136
|
|
|
699
2137
|
type MetricTotals = {
|
|
700
2138
|
events: number;
|
|
@@ -736,23 +2174,61 @@ function addMetrics(target: MetricTotals, source: MetricTotals) {
|
|
|
736
2174
|
|
|
737
2175
|
function validateSiteIds<T>(siteIds: T[]): T[] {
|
|
738
2176
|
const unique = [...new Set(siteIds)];
|
|
739
|
-
if (
|
|
740
|
-
|
|
2177
|
+
if (
|
|
2178
|
+
unique.length < 1 ||
|
|
2179
|
+
unique.length > MAX_REPORT_SITES ||
|
|
2180
|
+
unique.length !== siteIds.length
|
|
2181
|
+
) {
|
|
2182
|
+
fail(
|
|
2183
|
+
"INVALID_ARGUMENT",
|
|
2184
|
+
`siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`,
|
|
2185
|
+
);
|
|
741
2186
|
}
|
|
742
2187
|
return unique;
|
|
743
2188
|
}
|
|
744
2189
|
|
|
745
2190
|
function validateRange(from: number, to: number, maxDays: number) {
|
|
746
2191
|
if (
|
|
747
|
-
!Number.
|
|
748
|
-
!Number.
|
|
2192
|
+
!Number.isSafeInteger(from) ||
|
|
2193
|
+
!Number.isSafeInteger(to) ||
|
|
749
2194
|
from < 0 ||
|
|
750
|
-
to
|
|
751
|
-
to - from
|
|
2195
|
+
to < from ||
|
|
2196
|
+
to - from >= maxDays * DAY_MS
|
|
752
2197
|
) {
|
|
753
|
-
fail(
|
|
2198
|
+
fail(
|
|
2199
|
+
"INVALID_ARGUMENT",
|
|
2200
|
+
`range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`,
|
|
2201
|
+
);
|
|
754
2202
|
}
|
|
755
|
-
return { from
|
|
2203
|
+
return { from, to };
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
function isAlignedRange(from: number, to: number, intervalMs: number) {
|
|
2207
|
+
return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
function requireAlignedRange(
|
|
2211
|
+
from: number,
|
|
2212
|
+
to: number,
|
|
2213
|
+
intervalMs: number,
|
|
2214
|
+
report: string,
|
|
2215
|
+
) {
|
|
2216
|
+
if (!isAlignedRange(from, to, intervalMs)) {
|
|
2217
|
+
fail(
|
|
2218
|
+
"REPORT_INCOMPLETE",
|
|
2219
|
+
`${report} requires complete inclusive UTC bucket boundaries`,
|
|
2220
|
+
{ from, to, intervalMs, timezone: "UTC" },
|
|
2221
|
+
);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
function rangeSemantics(from: number, to: number) {
|
|
2226
|
+
return {
|
|
2227
|
+
from,
|
|
2228
|
+
to,
|
|
2229
|
+
boundary: "inclusive" as const,
|
|
2230
|
+
timezone: "UTC" as const,
|
|
2231
|
+
};
|
|
756
2232
|
}
|
|
757
2233
|
|
|
758
2234
|
type KeysetPaginationInput = {
|
|
@@ -881,22 +2357,23 @@ function keysetPaginationResult<T>(
|
|
|
881
2357
|
) {
|
|
882
2358
|
const page = rows.slice(0, pagination.numItems);
|
|
883
2359
|
const isDone = rows.length <= pagination.numItems;
|
|
884
|
-
const next =
|
|
885
|
-
? cursorFor(page[page.length - 1])
|
|
886
|
-
: pagination.cursor;
|
|
2360
|
+
const next =
|
|
2361
|
+
page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
|
|
887
2362
|
return {
|
|
888
2363
|
page,
|
|
889
2364
|
isDone,
|
|
890
|
-
continueCursor: btoa(
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
2365
|
+
continueCursor: btoa(
|
|
2366
|
+
JSON.stringify({
|
|
2367
|
+
v: 2,
|
|
2368
|
+
k: pagination.scope.kind,
|
|
2369
|
+
s: pagination.scope.siteId,
|
|
2370
|
+
n: pagination.scope.sessionId ?? null,
|
|
2371
|
+
f: pagination.scope.from,
|
|
2372
|
+
t: pagination.scope.to,
|
|
2373
|
+
x: next?.timestamp ?? pagination.scope.to,
|
|
2374
|
+
c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
|
|
2375
|
+
}),
|
|
2376
|
+
),
|
|
900
2377
|
};
|
|
901
2378
|
}
|
|
902
2379
|
|
|
@@ -908,26 +2385,28 @@ function journeyPaginationResult(
|
|
|
908
2385
|
const isDone = rows.length <= pagination.numItems;
|
|
909
2386
|
const last = page.at(-1);
|
|
910
2387
|
const next = last
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
2388
|
+
? {
|
|
2389
|
+
timestamp: last.timestamp,
|
|
2390
|
+
creationTime: last._creationTime,
|
|
2391
|
+
id: last._id,
|
|
915
2392
|
}
|
|
916
2393
|
: pagination.cursor;
|
|
917
2394
|
return {
|
|
918
2395
|
page,
|
|
919
2396
|
isDone,
|
|
920
|
-
continueCursor: btoa(
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
2397
|
+
continueCursor: btoa(
|
|
2398
|
+
JSON.stringify({
|
|
2399
|
+
v: 4,
|
|
2400
|
+
k: pagination.scope.kind,
|
|
2401
|
+
s: pagination.scope.siteId,
|
|
2402
|
+
n: pagination.scope.sessionId,
|
|
2403
|
+
f: pagination.scope.from,
|
|
2404
|
+
t: pagination.scope.to,
|
|
2405
|
+
x: next?.timestamp ?? pagination.scope.to,
|
|
2406
|
+
c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
|
|
2407
|
+
i: next?.id ?? "~",
|
|
2408
|
+
}),
|
|
2409
|
+
),
|
|
931
2410
|
};
|
|
932
2411
|
}
|
|
933
2412
|
|
|
@@ -936,8 +2415,15 @@ function validatePagination(options: {
|
|
|
936
2415
|
maximumRowsRead?: number;
|
|
937
2416
|
maximumBytesRead?: number;
|
|
938
2417
|
}) {
|
|
939
|
-
if (
|
|
940
|
-
|
|
2418
|
+
if (
|
|
2419
|
+
!Number.isSafeInteger(options.numItems) ||
|
|
2420
|
+
options.numItems < 1 ||
|
|
2421
|
+
options.numItems > 100
|
|
2422
|
+
) {
|
|
2423
|
+
fail(
|
|
2424
|
+
"INVALID_ARGUMENT",
|
|
2425
|
+
"pagination numItems must be an integer between 1 and 100",
|
|
2426
|
+
);
|
|
941
2427
|
}
|
|
942
2428
|
if (
|
|
943
2429
|
options.maximumRowsRead !== undefined &&
|
|
@@ -945,7 +2431,10 @@ function validatePagination(options: {
|
|
|
945
2431
|
options.maximumRowsRead < 1 ||
|
|
946
2432
|
options.maximumRowsRead > 1_000)
|
|
947
2433
|
) {
|
|
948
|
-
fail(
|
|
2434
|
+
fail(
|
|
2435
|
+
"INVALID_ARGUMENT",
|
|
2436
|
+
"maximumRowsRead must be an integer between 1 and 1000",
|
|
2437
|
+
);
|
|
949
2438
|
}
|
|
950
2439
|
if (
|
|
951
2440
|
options.maximumBytesRead !== undefined &&
|
|
@@ -953,6 +2442,9 @@ function validatePagination(options: {
|
|
|
953
2442
|
options.maximumBytesRead < 1 ||
|
|
954
2443
|
options.maximumBytesRead > 8_000_000)
|
|
955
2444
|
) {
|
|
956
|
-
fail(
|
|
2445
|
+
fail(
|
|
2446
|
+
"INVALID_ARGUMENT",
|
|
2447
|
+
"maximumBytesRead must be an integer between 1 and 8000000",
|
|
2448
|
+
);
|
|
957
2449
|
}
|
|
958
2450
|
}
|