@iann29/rastro 0.1.0-alpha.3 → 0.1.0-alpha.5
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 +374 -62
- package/agent/integration.md +651 -0
- package/agent/manifest.json +183 -0
- package/agent/manifest.schema.json +405 -0
- package/dist/client/federation.d.ts +205 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +179 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +1658 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +228 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +10 -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 +169 -1
- package/dist/component/_generated/component.d.ts.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 +11 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +11 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +18 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +19 -0
- package/dist/component/coverage.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 +1 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +85 -36
- 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 +1 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +258 -19
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +6 -3
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +120 -2
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +659 -91
- 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 +517 -54
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +4 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +13 -5
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +224 -7
- package/dist/component/schema.js +113 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +5 -1
- 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 +42 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +10 -1
- 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/tracker/generated.d.ts +4 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +4 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +23 -11
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/benchmarks/2026-08-20-realistic.md +4 -4
- package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
- package/docs/federation-setup.md +395 -0
- package/docs/federation.md +258 -0
- package/docs/upgrading.md +130 -0
- package/llms.txt +65 -0
- package/package.json +41 -11
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +228 -1
- package/src/component/cardinality.ts +116 -0
- package/src/component/constants.ts +11 -0
- package/src/component/coverage.ts +25 -0
- package/src/component/errors.ts +2 -1
- package/src/component/http.ts +129 -38
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +414 -13
- package/src/component/live.ts +8 -3
- package/src/component/reports.ts +910 -98
- package/src/component/retention.ts +624 -88
- package/src/component/sanitize.ts +19 -5
- package/src/component/schema.ts +125 -0
- package/src/component/sites.ts +5 -1
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +11 -0
- package/src/component/visitors.ts +105 -0
|
@@ -1,11 +1,13 @@
|
|
|
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, REPORT_ROLLUP_SHARDS, } from "./constants.js";
|
|
5
|
+
import { createVisitorSketch, estimateVisitors, unionVisitorSketches, } from "./cardinality.js";
|
|
5
6
|
import { fail } from "./errors.js";
|
|
6
7
|
import { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, loadVisitorJourneyEvents, } from "./eventStore.js";
|
|
7
8
|
import { isPlainRecord } from "./guards.js";
|
|
8
9
|
import { affiliateFieldsValidator, eventFieldsValidator, funnelFieldsValidator, goalFieldsValidator, sessionFieldsValidator, } from "./validators.js";
|
|
10
|
+
import { resolveVisitorIdentities } from "./visitors.js";
|
|
9
11
|
const sessionDocumentValidator = sessionFieldsValidator.extend({
|
|
10
12
|
_id: v.id("sessions"),
|
|
11
13
|
_creationTime: v.number(),
|
|
@@ -71,6 +73,25 @@ const topItemValidator = v.object({
|
|
|
71
73
|
count: v.number(),
|
|
72
74
|
revenueCents: v.number(),
|
|
73
75
|
});
|
|
76
|
+
const coverageStateValidator = v.union(v.literal("complete"), v.literal("partial"), v.literal("retained"), v.literal("unavailable"));
|
|
77
|
+
const resultCoverageValidator = v.object({
|
|
78
|
+
state: coverageStateValidator,
|
|
79
|
+
generation: v.union(v.number(), v.null()),
|
|
80
|
+
availableFrom: v.union(v.number(), v.null()),
|
|
81
|
+
availableThrough: v.union(v.number(), v.null()),
|
|
82
|
+
retainedBefore: v.union(v.number(), v.null()),
|
|
83
|
+
});
|
|
84
|
+
const rangeSemanticsValidator = v.object({
|
|
85
|
+
from: v.number(),
|
|
86
|
+
to: v.number(),
|
|
87
|
+
boundary: v.literal("inclusive"),
|
|
88
|
+
timezone: v.literal("UTC"),
|
|
89
|
+
});
|
|
90
|
+
const reportMetadataValidator = v.object({
|
|
91
|
+
basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
|
|
92
|
+
coverage: resultCoverageValidator,
|
|
93
|
+
range: rangeSemanticsValidator,
|
|
94
|
+
});
|
|
74
95
|
export const overview = query({
|
|
75
96
|
args: {
|
|
76
97
|
siteIds: v.array(v.id("sites")),
|
|
@@ -86,12 +107,40 @@ export const overview = query({
|
|
|
86
107
|
totals: metricTotalsValidator,
|
|
87
108
|
timeSeries: v.array(timePointValidator),
|
|
88
109
|
topSources: v.array(topItemValidator),
|
|
110
|
+
topCampaigns: v.array(topItemValidator),
|
|
89
111
|
topPages: v.array(topItemValidator),
|
|
90
112
|
topCountries: v.array(topItemValidator),
|
|
91
113
|
topDevices: v.array(topItemValidator),
|
|
92
114
|
topBrowsers: v.array(topItemValidator),
|
|
93
115
|
topEvents: v.array(topItemValidator),
|
|
94
116
|
topGoals: v.array(topItemValidator),
|
|
117
|
+
metadata: v.object({
|
|
118
|
+
visitors: v.object({
|
|
119
|
+
basis: v.union(v.literal("estimatedUnique"), v.literal("bucketOccurrences")),
|
|
120
|
+
exact: v.boolean(),
|
|
121
|
+
legacyIncomplete: v.boolean(),
|
|
122
|
+
sketchVersion: v.union(v.number(), v.null()),
|
|
123
|
+
precision: v.union(v.number(), v.null()),
|
|
124
|
+
}),
|
|
125
|
+
breakdowns: v.object({
|
|
126
|
+
topSources: v.literal("eventVolume"),
|
|
127
|
+
topCampaigns: v.literal("eventVolume"),
|
|
128
|
+
topPages: v.literal("eventVolume"),
|
|
129
|
+
topCountries: v.literal("eventVolume"),
|
|
130
|
+
topDevices: v.literal("eventVolume"),
|
|
131
|
+
topBrowsers: v.literal("eventVolume"),
|
|
132
|
+
topEvents: v.literal("eventVolume"),
|
|
133
|
+
topGoals: v.literal("goalCompletionVolume"),
|
|
134
|
+
}),
|
|
135
|
+
coverage: resultCoverageValidator,
|
|
136
|
+
range: rangeSemanticsValidator,
|
|
137
|
+
features: v.object({
|
|
138
|
+
sessionReplay: v.literal("unsupported"),
|
|
139
|
+
errorInsights: v.literal("unsupported"),
|
|
140
|
+
outboundLinks: v.literal("eventVolume"),
|
|
141
|
+
botDetection: v.literal("userAgent"),
|
|
142
|
+
}),
|
|
143
|
+
}),
|
|
95
144
|
}),
|
|
96
145
|
handler: async (ctx, args) => {
|
|
97
146
|
const siteIds = validateSiteIds(args.siteIds);
|
|
@@ -114,6 +163,7 @@ export const overview = query({
|
|
|
114
163
|
fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
|
|
115
164
|
}
|
|
116
165
|
const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
|
|
166
|
+
requireAlignedRange(from, to, intervalMs, `${interval} overview`);
|
|
117
167
|
const firstBucket = Math.floor(from / intervalMs) * intervalMs;
|
|
118
168
|
const lastBucket = Math.floor(to / intervalMs) * intervalMs;
|
|
119
169
|
const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
|
|
@@ -124,7 +174,15 @@ export const overview = query({
|
|
|
124
174
|
}
|
|
125
175
|
const totals = emptyTotals();
|
|
126
176
|
const points = new Map();
|
|
177
|
+
const visitorSketch = createVisitorSketch();
|
|
178
|
+
let mergedVisitorSketch = visitorSketch;
|
|
179
|
+
const pointVisitorSketches = new Map();
|
|
180
|
+
let visitorsComplete = true;
|
|
127
181
|
const dimensions = new Map();
|
|
182
|
+
const overviewDataset = interval === "hour"
|
|
183
|
+
? "overviewHour"
|
|
184
|
+
: "overviewDay";
|
|
185
|
+
await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
|
|
128
186
|
for (const siteId of siteIds) {
|
|
129
187
|
const buckets = await ctx.db
|
|
130
188
|
.query("aggregateBuckets")
|
|
@@ -133,12 +191,21 @@ export const overview = query({
|
|
|
133
191
|
.eq("granularity", interval)
|
|
134
192
|
.gte("bucketStart", firstBucket)
|
|
135
193
|
.lte("bucketStart", lastBucket))
|
|
136
|
-
.take(perSiteLimit);
|
|
194
|
+
.take(perSiteLimit + 1);
|
|
195
|
+
failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
|
|
137
196
|
for (const bucket of buckets) {
|
|
138
197
|
addMetrics(totals, bucket);
|
|
139
198
|
const point = points.get(bucket.bucketStart) ?? emptyTotals();
|
|
140
199
|
addMetrics(point, bucket);
|
|
141
200
|
points.set(bucket.bucketStart, point);
|
|
201
|
+
if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
|
|
202
|
+
mergedVisitorSketch = unionVisitorSketches(mergedVisitorSketch, bucket.visitorSketch);
|
|
203
|
+
pointVisitorSketches.set(bucket.bucketStart, unionVisitorSketches(pointVisitorSketches.get(bucket.bucketStart) ??
|
|
204
|
+
createVisitorSketch(bucket.visitorSketch.precision), bucket.visitorSketch));
|
|
205
|
+
}
|
|
206
|
+
else if (bucket.events > 0) {
|
|
207
|
+
visitorsComplete = false;
|
|
208
|
+
}
|
|
142
209
|
for (const dimension of bucket.dimensions) {
|
|
143
210
|
const key = `${dimension.type}:${dimension.value}`;
|
|
144
211
|
const current = dimensions.get(key) ?? {
|
|
@@ -157,6 +224,17 @@ export const overview = query({
|
|
|
157
224
|
.map(([, value]) => value)
|
|
158
225
|
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
|
|
159
226
|
.slice(0, 10);
|
|
227
|
+
if (visitorsComplete) {
|
|
228
|
+
totals.visitors = estimateVisitors(mergedVisitorSketch);
|
|
229
|
+
for (const [timestamp, point] of points) {
|
|
230
|
+
point.visitors = estimateVisitors(pointVisitorSketches.get(timestamp) ?? createVisitorSketch());
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const coverage = await coverageForRange(ctx, siteIds, from, to, {
|
|
234
|
+
datasets: [overviewDataset],
|
|
235
|
+
complete: visitorsComplete,
|
|
236
|
+
});
|
|
237
|
+
const visitorBasis = visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
|
|
160
238
|
return {
|
|
161
239
|
from,
|
|
162
240
|
to,
|
|
@@ -167,12 +245,40 @@ export const overview = query({
|
|
|
167
245
|
.sort(([left], [right]) => left - right)
|
|
168
246
|
.map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
|
|
169
247
|
topSources: top("source"),
|
|
248
|
+
topCampaigns: top("campaign"),
|
|
170
249
|
topPages: top("page"),
|
|
171
250
|
topCountries: top("country"),
|
|
172
251
|
topDevices: top("device"),
|
|
173
252
|
topBrowsers: top("browser"),
|
|
174
253
|
topEvents: top("event"),
|
|
175
254
|
topGoals: top("goal"),
|
|
255
|
+
metadata: {
|
|
256
|
+
visitors: {
|
|
257
|
+
basis: visitorBasis,
|
|
258
|
+
exact: false,
|
|
259
|
+
legacyIncomplete: !visitorsComplete,
|
|
260
|
+
sketchVersion: visitorsComplete ? 1 : null,
|
|
261
|
+
precision: visitorsComplete ? 6 : null,
|
|
262
|
+
},
|
|
263
|
+
breakdowns: {
|
|
264
|
+
topSources: "eventVolume",
|
|
265
|
+
topCampaigns: "eventVolume",
|
|
266
|
+
topPages: "eventVolume",
|
|
267
|
+
topCountries: "eventVolume",
|
|
268
|
+
topDevices: "eventVolume",
|
|
269
|
+
topBrowsers: "eventVolume",
|
|
270
|
+
topEvents: "eventVolume",
|
|
271
|
+
topGoals: "goalCompletionVolume",
|
|
272
|
+
},
|
|
273
|
+
coverage,
|
|
274
|
+
range: rangeSemantics(from, to),
|
|
275
|
+
features: {
|
|
276
|
+
sessionReplay: "unsupported",
|
|
277
|
+
errorInsights: "unsupported",
|
|
278
|
+
outboundLinks: "eventVolume",
|
|
279
|
+
botDetection: "userAgent",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
176
282
|
};
|
|
177
283
|
},
|
|
178
284
|
});
|
|
@@ -236,6 +342,7 @@ export const listSessions = query({
|
|
|
236
342
|
returns: paginationResultValidator(sessionDocumentValidator),
|
|
237
343
|
handler: async (ctx, args) => {
|
|
238
344
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
345
|
+
await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
|
|
239
346
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
240
347
|
kind: "sessions",
|
|
241
348
|
siteId: String(args.siteId),
|
|
@@ -292,6 +399,7 @@ export const sessionJourney = query({
|
|
|
292
399
|
returns: paginationResultValidator(eventDocumentValidator),
|
|
293
400
|
handler: async (ctx, args) => {
|
|
294
401
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
402
|
+
await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
|
|
295
403
|
const pagination = parseJourneyPagination(args.paginationOpts, {
|
|
296
404
|
kind: "events",
|
|
297
405
|
siteId: String(args.siteId),
|
|
@@ -321,6 +429,7 @@ export const listConversions = query({
|
|
|
321
429
|
returns: paginationResultValidator(conversionDocumentValidator),
|
|
322
430
|
handler: async (ctx, args) => {
|
|
323
431
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
432
|
+
await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
|
|
324
433
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
325
434
|
kind: "conversions",
|
|
326
435
|
siteId: String(args.siteId),
|
|
@@ -377,25 +486,28 @@ export const visitorJourney = query({
|
|
|
377
486
|
handler: async (ctx, args) => {
|
|
378
487
|
const siteIds = validateSiteIds(args.siteIds);
|
|
379
488
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
489
|
+
await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
|
|
380
490
|
const limit = args.limit ?? 200;
|
|
381
491
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
|
|
382
492
|
fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
|
|
383
493
|
}
|
|
384
494
|
const rows = [];
|
|
385
495
|
for (const siteId of siteIds) {
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
rows.length
|
|
496
|
+
for (const visitorId of await resolveVisitorIdentities(ctx, siteId, args.visitorId)) {
|
|
497
|
+
const cutoff = rows.length >= limit
|
|
498
|
+
? rows[limit - 1].timestamp
|
|
499
|
+
: args.to;
|
|
500
|
+
rows.push(...await loadVisitorJourneyEvents(ctx, {
|
|
501
|
+
siteId,
|
|
502
|
+
visitorId,
|
|
503
|
+
from: args.from,
|
|
504
|
+
to: cutoff,
|
|
505
|
+
resultLimit: limit,
|
|
506
|
+
}));
|
|
507
|
+
rows.sort(compareJourneyEvents);
|
|
508
|
+
if (rows.length > limit)
|
|
509
|
+
rows.length = limit;
|
|
510
|
+
}
|
|
399
511
|
}
|
|
400
512
|
return hydrateEventContexts(ctx, rows);
|
|
401
513
|
},
|
|
@@ -411,34 +523,63 @@ export const goalsReport = query({
|
|
|
411
523
|
returns: v.object({
|
|
412
524
|
goals: v.array(goalReportItemValidator),
|
|
413
525
|
truncated: v.boolean(),
|
|
526
|
+
metadata: reportMetadataValidator,
|
|
414
527
|
}),
|
|
415
528
|
handler: async (ctx, args) => {
|
|
416
529
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
417
|
-
const
|
|
530
|
+
const goalRows = await ctx.db
|
|
418
531
|
.query("goals")
|
|
419
532
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
420
|
-
.take(MAX_GOALS_PER_SITE);
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
.withIndex("by_siteId_and_timestamp", (range) => range
|
|
424
|
-
.eq("siteId", args.siteId)
|
|
425
|
-
.gte("timestamp", args.from)
|
|
426
|
-
.lte("timestamp", args.to))
|
|
427
|
-
.take(5_001);
|
|
533
|
+
.take(MAX_GOALS_PER_SITE + 1);
|
|
534
|
+
failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
|
|
535
|
+
const goals = goalRows;
|
|
428
536
|
const totals = new Map();
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
537
|
+
const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "goalRollups");
|
|
538
|
+
const rollups = useRollups
|
|
539
|
+
? await loadGoalDailyRollups(ctx, args.siteId, goals.map((goal) => goal._id), args.from, args.to)
|
|
540
|
+
: null;
|
|
541
|
+
if (rollups) {
|
|
542
|
+
for (const row of rollups) {
|
|
543
|
+
const key = String(row.goalId);
|
|
544
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
545
|
+
total.completions += row.completions;
|
|
546
|
+
total.valueCents += row.valueCents;
|
|
547
|
+
totals.set(key, total);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
await assertSourceAvailable(ctx, [args.siteId], ["goalCompletions"], args.from);
|
|
552
|
+
const completions = await ctx.db
|
|
553
|
+
.query("goalCompletions")
|
|
554
|
+
.withIndex("by_siteId_and_timestamp", (range) => range
|
|
555
|
+
.eq("siteId", args.siteId)
|
|
556
|
+
.gte("timestamp", args.from)
|
|
557
|
+
.lte("timestamp", args.to))
|
|
558
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
559
|
+
failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
|
|
560
|
+
for (const completion of completions) {
|
|
561
|
+
const key = String(completion.goalId);
|
|
562
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
563
|
+
total.completions += 1;
|
|
564
|
+
total.valueCents += completion.valueCents;
|
|
565
|
+
totals.set(key, total);
|
|
566
|
+
}
|
|
435
567
|
}
|
|
436
568
|
return {
|
|
437
569
|
goals: goals.map((goal) => ({
|
|
438
570
|
...goal,
|
|
439
571
|
...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
|
|
440
572
|
})),
|
|
441
|
-
truncated:
|
|
573
|
+
truncated: false,
|
|
574
|
+
metadata: {
|
|
575
|
+
basis: rollups ? "dailyRollup" : "boundedLegacy",
|
|
576
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
577
|
+
datasets: [rollups ? "goalRollups" : "goalCompletions"],
|
|
578
|
+
complete: true,
|
|
579
|
+
generation: rollups ? 1 : null,
|
|
580
|
+
}),
|
|
581
|
+
range: rangeSemantics(args.from, args.to),
|
|
582
|
+
},
|
|
442
583
|
};
|
|
443
584
|
},
|
|
444
585
|
});
|
|
@@ -447,33 +588,56 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
|
|
|
447
588
|
_creationTime: v.number(),
|
|
448
589
|
stepsReport: v.array(v.object({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() })),
|
|
449
590
|
truncated: v.boolean(),
|
|
591
|
+
metadata: reportMetadataValidator,
|
|
450
592
|
});
|
|
451
593
|
export const funnelsReport = query({
|
|
452
594
|
args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
|
|
453
595
|
returns: v.array(funnelReportItemValidator),
|
|
454
596
|
handler: async (ctx, args) => {
|
|
455
597
|
validateRange(args.from, args.to, 90);
|
|
456
|
-
|
|
598
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
|
|
599
|
+
const funnelRows = await ctx.db
|
|
457
600
|
.query("funnels")
|
|
458
601
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
459
|
-
.take(MAX_FUNNELS_PER_SITE);
|
|
602
|
+
.take(MAX_FUNNELS_PER_SITE + 1);
|
|
603
|
+
failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
|
|
604
|
+
const funnels = funnelRows;
|
|
605
|
+
const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "funnelRollups");
|
|
606
|
+
const rollups = useRollups
|
|
607
|
+
? await loadFunnelDailyRollups(ctx, args.siteId, funnels.map((funnel) => funnel._id), args.from, args.to)
|
|
608
|
+
: null;
|
|
609
|
+
if (!rollups) {
|
|
610
|
+
await assertSourceAvailable(ctx, [args.siteId], ["funnelSteps"], args.from);
|
|
611
|
+
}
|
|
612
|
+
const reportCoverage = await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
613
|
+
datasets: [rollups ? "funnelRollups" : "funnelSteps"],
|
|
614
|
+
complete: true,
|
|
615
|
+
generation: rollups ? 1 : null,
|
|
616
|
+
});
|
|
460
617
|
const reports = [];
|
|
461
618
|
for (const funnel of funnels) {
|
|
462
|
-
const buckets = await ctx.db
|
|
463
|
-
.query("funnelStepBuckets")
|
|
464
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
|
|
465
|
-
.eq("siteId", args.siteId)
|
|
466
|
-
.eq("funnelId", funnel._id)
|
|
467
|
-
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
468
|
-
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
469
|
-
.take(601);
|
|
470
619
|
const stepsReport = funnel.steps.map((step, index) => ({
|
|
471
620
|
step: index,
|
|
472
621
|
name: step.name,
|
|
473
622
|
entrants: 0,
|
|
474
623
|
completions: 0,
|
|
475
624
|
}));
|
|
476
|
-
|
|
625
|
+
const buckets = rollups
|
|
626
|
+
? rollups
|
|
627
|
+
.filter((row) => row.funnelId === funnel._id)
|
|
628
|
+
.flatMap((row) => row.steps)
|
|
629
|
+
: await ctx.db
|
|
630
|
+
.query("funnelStepBuckets")
|
|
631
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
|
|
632
|
+
.eq("siteId", args.siteId)
|
|
633
|
+
.eq("funnelId", funnel._id)
|
|
634
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
635
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
636
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
637
|
+
if (!rollups) {
|
|
638
|
+
failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
|
|
639
|
+
}
|
|
640
|
+
for (const bucket of buckets) {
|
|
477
641
|
const step = stepsReport[bucket.step];
|
|
478
642
|
if (step) {
|
|
479
643
|
step.entrants += bucket.entrants;
|
|
@@ -483,7 +647,12 @@ export const funnelsReport = query({
|
|
|
483
647
|
reports.push({
|
|
484
648
|
...funnel,
|
|
485
649
|
stepsReport,
|
|
486
|
-
truncated:
|
|
650
|
+
truncated: false,
|
|
651
|
+
metadata: {
|
|
652
|
+
basis: rollups ? "dailyRollup" : "boundedLegacy",
|
|
653
|
+
coverage: reportCoverage,
|
|
654
|
+
range: rangeSemantics(args.from, args.to),
|
|
655
|
+
},
|
|
487
656
|
});
|
|
488
657
|
}
|
|
489
658
|
return reports;
|
|
@@ -503,54 +672,94 @@ export const affiliatesReport = query({
|
|
|
503
672
|
returns: v.object({
|
|
504
673
|
affiliates: v.array(affiliateReportItemValidator),
|
|
505
674
|
truncated: v.boolean(),
|
|
675
|
+
metadata: reportMetadataValidator.extend({
|
|
676
|
+
uniqueVisitors: v.object({
|
|
677
|
+
basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
|
|
678
|
+
exact: v.boolean(),
|
|
679
|
+
}),
|
|
680
|
+
}),
|
|
506
681
|
}),
|
|
507
682
|
handler: async (ctx, args) => {
|
|
508
683
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
684
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
|
|
509
685
|
const affiliateRows = await ctx.db
|
|
510
686
|
.query("affiliates")
|
|
511
687
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
512
688
|
.order("desc")
|
|
513
689
|
.take(MAX_AFFILIATES_PER_SITE + 1);
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
521
|
-
.take(5_001);
|
|
522
|
-
const sessions = await ctx.db
|
|
523
|
-
.query("sessions")
|
|
524
|
-
.withIndex("by_siteId_and_startedAt", (range) => range
|
|
525
|
-
.eq("siteId", args.siteId)
|
|
526
|
-
.gte("startedAt", args.from)
|
|
527
|
-
.lte("startedAt", args.to))
|
|
528
|
-
.take(5_001);
|
|
690
|
+
failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
|
|
691
|
+
const affiliates = affiliateRows;
|
|
692
|
+
const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "affiliateRollups");
|
|
693
|
+
const rollups = useRollups
|
|
694
|
+
? await loadAffiliateDailyRollups(ctx, args.siteId, affiliates.map((affiliate) => affiliate._id), args.from, args.to)
|
|
695
|
+
: null;
|
|
529
696
|
const visits = new Map();
|
|
530
|
-
for (const session of sessions.slice(0, 5_000)) {
|
|
531
|
-
if (!session.affiliateId)
|
|
532
|
-
continue;
|
|
533
|
-
const key = String(session.affiliateId);
|
|
534
|
-
const total = visits.get(key) ?? {
|
|
535
|
-
visits: 0,
|
|
536
|
-
visitors: new Set(),
|
|
537
|
-
};
|
|
538
|
-
total.visits += 1;
|
|
539
|
-
total.visitors.add(session.visitorId);
|
|
540
|
-
visits.set(key, total);
|
|
541
|
-
}
|
|
542
697
|
const totals = new Map();
|
|
543
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
698
|
+
if (rollups) {
|
|
699
|
+
for (const row of rollups) {
|
|
700
|
+
const key = String(row.affiliateId);
|
|
701
|
+
const traffic = visits.get(key) ?? {
|
|
702
|
+
visits: 0,
|
|
703
|
+
visitors: new Set(),
|
|
704
|
+
sketch: createVisitorSketch(),
|
|
705
|
+
};
|
|
706
|
+
traffic.visits += row.visits;
|
|
707
|
+
traffic.sketch = unionVisitorSketches(traffic.sketch, row.visitorSketch);
|
|
708
|
+
visits.set(key, traffic);
|
|
709
|
+
const total = totals.get(key) ?? {
|
|
710
|
+
conversions: 0,
|
|
711
|
+
revenueCents: 0,
|
|
712
|
+
commissionCents: 0,
|
|
713
|
+
};
|
|
714
|
+
total.conversions += row.conversions;
|
|
715
|
+
total.revenueCents += row.revenueCents;
|
|
716
|
+
total.commissionCents += row.commissionCents;
|
|
717
|
+
totals.set(key, total);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
await assertSourceAvailable(ctx, [args.siteId], ["affiliateStats", "sessions"], args.from);
|
|
722
|
+
const stats = await ctx.db
|
|
723
|
+
.query("affiliateDailyStats")
|
|
724
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range
|
|
725
|
+
.eq("siteId", args.siteId)
|
|
726
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
727
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
728
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
729
|
+
const sessions = await ctx.db
|
|
730
|
+
.query("sessions")
|
|
731
|
+
.withIndex("by_siteId_and_startedAt", (range) => range
|
|
732
|
+
.eq("siteId", args.siteId)
|
|
733
|
+
.gte("startedAt", args.from)
|
|
734
|
+
.lte("startedAt", args.to))
|
|
735
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
736
|
+
failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
|
|
737
|
+
failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
|
|
738
|
+
for (const session of sessions) {
|
|
739
|
+
if (!session.affiliateId)
|
|
740
|
+
continue;
|
|
741
|
+
const key = String(session.affiliateId);
|
|
742
|
+
const total = visits.get(key) ?? {
|
|
743
|
+
visits: 0,
|
|
744
|
+
visitors: new Set(),
|
|
745
|
+
sketch: createVisitorSketch(),
|
|
746
|
+
};
|
|
747
|
+
total.visits += 1;
|
|
748
|
+
total.visitors.add(session.visitorId);
|
|
749
|
+
visits.set(key, total);
|
|
750
|
+
}
|
|
751
|
+
for (const row of stats) {
|
|
752
|
+
const key = String(row.affiliateId);
|
|
753
|
+
const total = totals.get(key) ?? {
|
|
754
|
+
conversions: 0,
|
|
755
|
+
revenueCents: 0,
|
|
756
|
+
commissionCents: 0,
|
|
757
|
+
};
|
|
758
|
+
total.conversions += row.conversions;
|
|
759
|
+
total.revenueCents += row.revenueCents;
|
|
760
|
+
total.commissionCents += row.commissionCents;
|
|
761
|
+
totals.set(key, total);
|
|
762
|
+
}
|
|
554
763
|
}
|
|
555
764
|
return {
|
|
556
765
|
affiliates: affiliates.map((affiliate) => {
|
|
@@ -564,15 +773,358 @@ export const affiliatesReport = query({
|
|
|
564
773
|
...affiliate,
|
|
565
774
|
...performance,
|
|
566
775
|
visits: traffic?.visits ?? 0,
|
|
567
|
-
uniqueVisitors:
|
|
776
|
+
uniqueVisitors: rollups
|
|
777
|
+
? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
|
|
778
|
+
: traffic?.visitors.size ?? 0,
|
|
568
779
|
};
|
|
569
780
|
}),
|
|
570
|
-
truncated:
|
|
571
|
-
|
|
572
|
-
|
|
781
|
+
truncated: false,
|
|
782
|
+
metadata: {
|
|
783
|
+
basis: rollups ? "dailyRollup" : "boundedLegacy",
|
|
784
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
785
|
+
datasets: rollups
|
|
786
|
+
? ["affiliateRollups"]
|
|
787
|
+
: ["affiliateStats", "sessions"],
|
|
788
|
+
complete: true,
|
|
789
|
+
generation: rollups ? 1 : null,
|
|
790
|
+
}),
|
|
791
|
+
range: rangeSemantics(args.from, args.to),
|
|
792
|
+
uniqueVisitors: {
|
|
793
|
+
basis: rollups ? "estimatedUnique" : "exactLegacy",
|
|
794
|
+
exact: !rollups,
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
};
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
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"));
|
|
801
|
+
export const dataCoverage = query({
|
|
802
|
+
args: {
|
|
803
|
+
siteId: v.id("sites"),
|
|
804
|
+
from: v.number(),
|
|
805
|
+
to: v.number(),
|
|
806
|
+
},
|
|
807
|
+
returns: v.object({
|
|
808
|
+
siteId: v.id("sites"),
|
|
809
|
+
from: v.number(),
|
|
810
|
+
to: v.number(),
|
|
811
|
+
datasets: v.array(v.object({
|
|
812
|
+
dataset: coverageDatasetValidator,
|
|
813
|
+
coverage: resultCoverageValidator,
|
|
814
|
+
})),
|
|
815
|
+
}),
|
|
816
|
+
handler: async (ctx, args) => {
|
|
817
|
+
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
818
|
+
const datasets = [
|
|
819
|
+
"overviewHour",
|
|
820
|
+
"overviewDay",
|
|
821
|
+
"events",
|
|
822
|
+
"sessions",
|
|
823
|
+
"conversions",
|
|
824
|
+
"goals",
|
|
825
|
+
"funnels",
|
|
826
|
+
"affiliates",
|
|
827
|
+
];
|
|
828
|
+
return {
|
|
829
|
+
siteId: args.siteId,
|
|
830
|
+
from: args.from,
|
|
831
|
+
to: args.to,
|
|
832
|
+
datasets: await Promise.all(datasets.map(async (dataset) => ({
|
|
833
|
+
dataset,
|
|
834
|
+
coverage: await datasetCoverageForRange(ctx, args.siteId, dataset, args.from, args.to),
|
|
835
|
+
}))),
|
|
573
836
|
};
|
|
574
837
|
},
|
|
575
838
|
});
|
|
839
|
+
async function dailyRollupsAvailable(ctx, siteId, from, to, dataset) {
|
|
840
|
+
if (!isAlignedRange(from, to, DAY_MS))
|
|
841
|
+
return false;
|
|
842
|
+
const coverage = await ctx.db
|
|
843
|
+
.query("analyticsCoverage")
|
|
844
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
845
|
+
.unique();
|
|
846
|
+
if (!coverage || coverage.rollupCompleteFrom > from) {
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
const retainedBefore = coverage.retained.find((item) => item.dataset === dataset)?.before;
|
|
850
|
+
return retainedBefore === undefined || from >= retainedBefore;
|
|
851
|
+
}
|
|
852
|
+
async function loadGoalDailyRollups(ctx, siteId, goalIds, from, to) {
|
|
853
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
854
|
+
requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
|
|
855
|
+
const rows = [];
|
|
856
|
+
for (const goalId of goalIds) {
|
|
857
|
+
const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
|
|
858
|
+
const goalRows = await ctx.db
|
|
859
|
+
.query("goalDailyRollups")
|
|
860
|
+
.withIndex("by_siteId_and_goalId_and_bucketStart", (range) => range
|
|
861
|
+
.eq("siteId", siteId)
|
|
862
|
+
.eq("goalId", goalId)
|
|
863
|
+
.gte("bucketStart", from)
|
|
864
|
+
.lte("bucketStart", to))
|
|
865
|
+
.take(perGoalLimit + 1);
|
|
866
|
+
failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
|
|
867
|
+
rows.push(...goalRows);
|
|
868
|
+
await requireFeatureReadHeadroom(ctx, "goal daily rollups");
|
|
869
|
+
}
|
|
870
|
+
return rows;
|
|
871
|
+
}
|
|
872
|
+
async function loadFunnelDailyRollups(ctx, siteId, funnelIds, from, to) {
|
|
873
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
874
|
+
requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
|
|
875
|
+
const rows = [];
|
|
876
|
+
for (const funnelId of funnelIds) {
|
|
877
|
+
const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
|
|
878
|
+
const funnelRows = await ctx.db
|
|
879
|
+
.query("funnelDailyRollups")
|
|
880
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
|
|
881
|
+
.eq("siteId", siteId)
|
|
882
|
+
.eq("funnelId", funnelId)
|
|
883
|
+
.gte("bucketStart", from)
|
|
884
|
+
.lte("bucketStart", to))
|
|
885
|
+
.take(perFunnelLimit + 1);
|
|
886
|
+
failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
|
|
887
|
+
rows.push(...funnelRows);
|
|
888
|
+
await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
|
|
889
|
+
}
|
|
890
|
+
return rows;
|
|
891
|
+
}
|
|
892
|
+
async function loadAffiliateDailyRollups(ctx, siteId, affiliateIds, from, to) {
|
|
893
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
894
|
+
requireFeatureRollupBudget(affiliateIds.length, days, "affiliate daily rollups");
|
|
895
|
+
const rows = [];
|
|
896
|
+
for (const affiliateId of affiliateIds) {
|
|
897
|
+
const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
|
|
898
|
+
const affiliateRows = await ctx.db
|
|
899
|
+
.query("affiliateDailyRollups")
|
|
900
|
+
.withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) => range
|
|
901
|
+
.eq("siteId", siteId)
|
|
902
|
+
.eq("affiliateId", affiliateId)
|
|
903
|
+
.gte("bucketStart", from)
|
|
904
|
+
.lte("bucketStart", to))
|
|
905
|
+
.take(perAffiliateLimit + 1);
|
|
906
|
+
failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
|
|
907
|
+
rows.push(...affiliateRows);
|
|
908
|
+
await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
|
|
909
|
+
}
|
|
910
|
+
return rows;
|
|
911
|
+
}
|
|
912
|
+
function requireFeatureRollupBudget(entities, days, dataset) {
|
|
913
|
+
const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
|
|
914
|
+
if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
|
|
915
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
|
|
916
|
+
dataset,
|
|
917
|
+
maximumDays: MAX_FEATURE_ROLLUP_DAYS,
|
|
918
|
+
maximumRows: MAX_FEATURE_ROLLUP_ROWS,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async function requireFeatureReadHeadroom(ctx, dataset) {
|
|
923
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
924
|
+
if (metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
|
|
925
|
+
metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE) {
|
|
926
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
|
|
927
|
+
dataset,
|
|
928
|
+
bytesRead: metrics.bytesRead.used,
|
|
929
|
+
documentsRead: metrics.documentsRead.used,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
function failIfIncomplete(rows, maximum, dataset) {
|
|
934
|
+
if (rows.length > maximum) {
|
|
935
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
|
|
936
|
+
maximumRows: maximum,
|
|
937
|
+
dataset,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
async function coverageForRange(ctx, siteIds, from, to, options) {
|
|
942
|
+
const controls = await Promise.all(siteIds.map((siteId) => ctx.db
|
|
943
|
+
.query("analyticsCoverage")
|
|
944
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
945
|
+
.unique()));
|
|
946
|
+
const sourceRanges = await Promise.all(siteIds.map((siteId) => sourceSetRange(ctx, siteId, options.datasets)));
|
|
947
|
+
const retainedBefore = controls.reduce((latest, control) => {
|
|
948
|
+
const before = control?.retained
|
|
949
|
+
.filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
|
|
950
|
+
.reduce((latest, item) => Math.max(latest ?? 0, item.before), undefined);
|
|
951
|
+
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
952
|
+
}, null);
|
|
953
|
+
const availableFrom = options.generation === 1
|
|
954
|
+
? controls.length > 0 && controls.every(Boolean)
|
|
955
|
+
? Math.max(...controls.map((control) => control.rollupCompleteFrom))
|
|
956
|
+
: null
|
|
957
|
+
: sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
958
|
+
? Math.max(...sourceRanges.map((range) => range.from))
|
|
959
|
+
: null;
|
|
960
|
+
const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
961
|
+
? Math.min(...sourceRanges.map((range) => range.through))
|
|
962
|
+
: null;
|
|
963
|
+
let state;
|
|
964
|
+
if (retainedBefore !== null && to < retainedBefore) {
|
|
965
|
+
state = "unavailable";
|
|
966
|
+
}
|
|
967
|
+
else if (retainedBefore !== null && from < retainedBefore) {
|
|
968
|
+
state = "partial";
|
|
969
|
+
}
|
|
970
|
+
else if (options.complete || (availableFrom !== null &&
|
|
971
|
+
availableThrough !== null &&
|
|
972
|
+
from >= availableFrom &&
|
|
973
|
+
to <= availableThrough)) {
|
|
974
|
+
state = "complete";
|
|
975
|
+
}
|
|
976
|
+
else if (sourceRanges.some(Boolean)) {
|
|
977
|
+
state = "retained";
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
state = "unavailable";
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
state,
|
|
984
|
+
generation: options.generation ?? null,
|
|
985
|
+
availableFrom,
|
|
986
|
+
availableThrough,
|
|
987
|
+
retainedBefore,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
async function assertSourceAvailable(ctx, siteIds, sources, from) {
|
|
991
|
+
const rows = await Promise.all(siteIds.map((siteId) => ctx.db
|
|
992
|
+
.query("analyticsCoverage")
|
|
993
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
994
|
+
.unique()));
|
|
995
|
+
const cutoff = rows.reduce((latest, row) => {
|
|
996
|
+
for (const retained of row?.retained ?? []) {
|
|
997
|
+
if (sources.some((source) => source === retained.dataset)) {
|
|
998
|
+
latest = Math.max(latest ?? 0, retained.before);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return latest;
|
|
1002
|
+
}, null);
|
|
1003
|
+
if (cutoff !== null && from < cutoff) {
|
|
1004
|
+
fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
|
|
1005
|
+
retainedBefore: cutoff,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
async function datasetCoverageForRange(ctx, siteId, dataset, from, to) {
|
|
1010
|
+
const sourceOptions = dataset === "goals"
|
|
1011
|
+
? [["goalRollups"], ["goalCompletions"]]
|
|
1012
|
+
: dataset === "funnels"
|
|
1013
|
+
? [["funnelRollups"], ["funnelSteps"]]
|
|
1014
|
+
: dataset === "affiliates"
|
|
1015
|
+
? [["affiliateRollups"], ["affiliateStats", "sessions"]]
|
|
1016
|
+
: [[dataset]];
|
|
1017
|
+
const options = (await Promise.all(sourceOptions.map(async (sources) => {
|
|
1018
|
+
const rollupDataset = sources[0];
|
|
1019
|
+
const isRollup = rollupDataset.endsWith("Rollups");
|
|
1020
|
+
const complete = isRollup
|
|
1021
|
+
? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
|
|
1022
|
+
: false;
|
|
1023
|
+
if (isRollup && !complete)
|
|
1024
|
+
return null;
|
|
1025
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1026
|
+
datasets: sources,
|
|
1027
|
+
complete,
|
|
1028
|
+
generation: complete ? 1 : null,
|
|
1029
|
+
});
|
|
1030
|
+
}))).filter((option) => option !== null);
|
|
1031
|
+
return options.sort((left, right) => coverageRank(right.state) - coverageRank(left.state))[0];
|
|
1032
|
+
}
|
|
1033
|
+
async function sourceSetRange(ctx, siteId, sources) {
|
|
1034
|
+
const ranges = (await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))).filter((range) => range !== null);
|
|
1035
|
+
if (ranges.length === 0)
|
|
1036
|
+
return null;
|
|
1037
|
+
return {
|
|
1038
|
+
from: Math.min(...ranges.map((range) => range.from)),
|
|
1039
|
+
through: Math.max(...ranges.map((range) => range.through)),
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
async function sourceRange(ctx, siteId, source) {
|
|
1043
|
+
if (source === "overviewHour" || source === "overviewDay") {
|
|
1044
|
+
const granularity = source === "overviewHour" ? "hour" : "day";
|
|
1045
|
+
const [first, last] = await Promise.all([
|
|
1046
|
+
ctx.db.query("aggregateBuckets")
|
|
1047
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity)).order("asc").take(1),
|
|
1048
|
+
ctx.db.query("aggregateBuckets")
|
|
1049
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity)).order("desc").take(1),
|
|
1050
|
+
]);
|
|
1051
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1052
|
+
}
|
|
1053
|
+
if (source === "events") {
|
|
1054
|
+
const [first, last] = await Promise.all([
|
|
1055
|
+
ctx.db.query("eventBatches")
|
|
1056
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1057
|
+
.order("asc").take(1),
|
|
1058
|
+
ctx.db.query("eventBatches")
|
|
1059
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1060
|
+
.order("desc").take(1),
|
|
1061
|
+
]);
|
|
1062
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1063
|
+
}
|
|
1064
|
+
if (source === "sessions") {
|
|
1065
|
+
const [first, last] = await Promise.all([
|
|
1066
|
+
ctx.db.query("sessions")
|
|
1067
|
+
.withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
|
|
1068
|
+
.order("asc").take(1),
|
|
1069
|
+
ctx.db.query("sessions")
|
|
1070
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
|
|
1071
|
+
.order("desc").take(1),
|
|
1072
|
+
]);
|
|
1073
|
+
return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
|
|
1074
|
+
}
|
|
1075
|
+
if (source === "conversions") {
|
|
1076
|
+
const [first, last] = await Promise.all([
|
|
1077
|
+
ctx.db.query("conversions")
|
|
1078
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1079
|
+
.order("asc").take(1),
|
|
1080
|
+
ctx.db.query("conversions")
|
|
1081
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1082
|
+
.order("desc").take(1),
|
|
1083
|
+
]);
|
|
1084
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1085
|
+
}
|
|
1086
|
+
if (source === "goalCompletions") {
|
|
1087
|
+
const [first, last] = await Promise.all([
|
|
1088
|
+
ctx.db.query("goalCompletions")
|
|
1089
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1090
|
+
.order("asc").take(1),
|
|
1091
|
+
ctx.db.query("goalCompletions")
|
|
1092
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1093
|
+
.order("desc").take(1),
|
|
1094
|
+
]);
|
|
1095
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1096
|
+
}
|
|
1097
|
+
const table = source === "funnelSteps"
|
|
1098
|
+
? "funnelStepBuckets"
|
|
1099
|
+
: source === "affiliateStats"
|
|
1100
|
+
? "affiliateDailyStats"
|
|
1101
|
+
: source === "goalRollups"
|
|
1102
|
+
? "goalDailyRollups"
|
|
1103
|
+
: source === "funnelRollups"
|
|
1104
|
+
? "funnelDailyRollups"
|
|
1105
|
+
: "affiliateDailyRollups";
|
|
1106
|
+
const [first, last] = await Promise.all([
|
|
1107
|
+
ctx.db.query(table)
|
|
1108
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1109
|
+
.order("asc").take(1),
|
|
1110
|
+
ctx.db.query(table)
|
|
1111
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1112
|
+
.order("desc").take(1),
|
|
1113
|
+
]);
|
|
1114
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1115
|
+
}
|
|
1116
|
+
function bounds(from, through) {
|
|
1117
|
+
return from === undefined || through === undefined ? null : { from, through };
|
|
1118
|
+
}
|
|
1119
|
+
function coverageRank(state) {
|
|
1120
|
+
if (state === "complete")
|
|
1121
|
+
return 3;
|
|
1122
|
+
if (state === "retained")
|
|
1123
|
+
return 2;
|
|
1124
|
+
if (state === "partial")
|
|
1125
|
+
return 1;
|
|
1126
|
+
return 0;
|
|
1127
|
+
}
|
|
576
1128
|
function emptyTotals() {
|
|
577
1129
|
return {
|
|
578
1130
|
events: 0,
|
|
@@ -605,14 +1157,30 @@ function validateSiteIds(siteIds) {
|
|
|
605
1157
|
return unique;
|
|
606
1158
|
}
|
|
607
1159
|
function validateRange(from, to, maxDays) {
|
|
608
|
-
if (!Number.
|
|
609
|
-
!Number.
|
|
1160
|
+
if (!Number.isSafeInteger(from) ||
|
|
1161
|
+
!Number.isSafeInteger(to) ||
|
|
610
1162
|
from < 0 ||
|
|
611
|
-
to
|
|
612
|
-
to - from
|
|
613
|
-
fail("INVALID_ARGUMENT", `range must
|
|
1163
|
+
to < from ||
|
|
1164
|
+
to - from >= maxDays * DAY_MS) {
|
|
1165
|
+
fail("INVALID_ARGUMENT", `range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`);
|
|
1166
|
+
}
|
|
1167
|
+
return { from, to };
|
|
1168
|
+
}
|
|
1169
|
+
function isAlignedRange(from, to, intervalMs) {
|
|
1170
|
+
return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
|
|
1171
|
+
}
|
|
1172
|
+
function requireAlignedRange(from, to, intervalMs, report) {
|
|
1173
|
+
if (!isAlignedRange(from, to, intervalMs)) {
|
|
1174
|
+
fail("REPORT_INCOMPLETE", `${report} requires complete inclusive UTC bucket boundaries`, { from, to, intervalMs, timezone: "UTC" });
|
|
614
1175
|
}
|
|
615
|
-
|
|
1176
|
+
}
|
|
1177
|
+
function rangeSemantics(from, to) {
|
|
1178
|
+
return {
|
|
1179
|
+
from,
|
|
1180
|
+
to,
|
|
1181
|
+
boundary: "inclusive",
|
|
1182
|
+
timezone: "UTC",
|
|
1183
|
+
};
|
|
616
1184
|
}
|
|
617
1185
|
function parseKeysetPagination(options, scope) {
|
|
618
1186
|
validatePagination(options);
|