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