@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
package/src/component/reports.ts
CHANGED
|
@@ -3,22 +3,35 @@ import {
|
|
|
3
3
|
paginationResultValidator,
|
|
4
4
|
} from "convex/server";
|
|
5
5
|
import { v } from "convex/values";
|
|
6
|
-
import type { Doc } from "./_generated/dataModel.js";
|
|
6
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
7
|
+
import type { QueryCtx } from "./_generated/server.js";
|
|
7
8
|
import { query } from "./_generated/server.js";
|
|
8
9
|
import {
|
|
9
10
|
AGGREGATE_SHARDS,
|
|
10
11
|
DAY_MS,
|
|
12
|
+
FEATURE_REPORT_BYTE_RESERVE,
|
|
13
|
+
FEATURE_REPORT_DOCUMENT_RESERVE,
|
|
11
14
|
HOUR_MS,
|
|
12
15
|
HOURLY_AGGREGATE_SHARDS,
|
|
13
16
|
MAX_AFFILIATES_PER_SITE,
|
|
14
17
|
MAX_FUNNELS_PER_SITE,
|
|
18
|
+
MAX_FEATURE_ROLLUP_DAYS,
|
|
19
|
+
MAX_FEATURE_ROLLUP_ROWS,
|
|
15
20
|
MAX_GOALS_PER_SITE,
|
|
16
21
|
MAX_JOURNEY_EVENTS,
|
|
17
22
|
MAX_LIVE_VISITORS,
|
|
18
23
|
MAX_OVERVIEW_AGGREGATE_ROWS,
|
|
24
|
+
MAX_REPORT_BUCKETS,
|
|
19
25
|
MAX_REPORT_RANGE_DAYS,
|
|
20
26
|
MAX_REPORT_SITES,
|
|
27
|
+
REPORT_ROLLUP_SHARDS,
|
|
21
28
|
} from "./constants.js";
|
|
29
|
+
import {
|
|
30
|
+
createVisitorSketch,
|
|
31
|
+
estimateVisitors,
|
|
32
|
+
unionVisitorSketches,
|
|
33
|
+
type VisitorSketch,
|
|
34
|
+
} from "./cardinality.js";
|
|
22
35
|
import { fail } from "./errors.js";
|
|
23
36
|
import {
|
|
24
37
|
compareJourneyEvents,
|
|
@@ -102,6 +115,30 @@ const topItemValidator = v.object({
|
|
|
102
115
|
count: v.number(),
|
|
103
116
|
revenueCents: v.number(),
|
|
104
117
|
});
|
|
118
|
+
const coverageStateValidator = v.union(
|
|
119
|
+
v.literal("complete"),
|
|
120
|
+
v.literal("partial"),
|
|
121
|
+
v.literal("retained"),
|
|
122
|
+
v.literal("unavailable"),
|
|
123
|
+
);
|
|
124
|
+
const resultCoverageValidator = v.object({
|
|
125
|
+
state: coverageStateValidator,
|
|
126
|
+
generation: v.union(v.number(), v.null()),
|
|
127
|
+
availableFrom: v.union(v.number(), v.null()),
|
|
128
|
+
availableThrough: v.union(v.number(), v.null()),
|
|
129
|
+
retainedBefore: v.union(v.number(), v.null()),
|
|
130
|
+
});
|
|
131
|
+
const rangeSemanticsValidator = v.object({
|
|
132
|
+
from: v.number(),
|
|
133
|
+
to: v.number(),
|
|
134
|
+
boundary: v.literal("inclusive"),
|
|
135
|
+
timezone: v.literal("UTC"),
|
|
136
|
+
});
|
|
137
|
+
const reportMetadataValidator = v.object({
|
|
138
|
+
basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
|
|
139
|
+
coverage: resultCoverageValidator,
|
|
140
|
+
range: rangeSemanticsValidator,
|
|
141
|
+
});
|
|
105
142
|
|
|
106
143
|
export const overview = query({
|
|
107
144
|
args: {
|
|
@@ -124,6 +161,35 @@ export const overview = query({
|
|
|
124
161
|
topBrowsers: v.array(topItemValidator),
|
|
125
162
|
topEvents: v.array(topItemValidator),
|
|
126
163
|
topGoals: v.array(topItemValidator),
|
|
164
|
+
metadata: v.object({
|
|
165
|
+
visitors: v.object({
|
|
166
|
+
basis: v.union(
|
|
167
|
+
v.literal("estimatedUnique"),
|
|
168
|
+
v.literal("bucketOccurrences"),
|
|
169
|
+
),
|
|
170
|
+
exact: v.boolean(),
|
|
171
|
+
legacyIncomplete: v.boolean(),
|
|
172
|
+
sketchVersion: v.union(v.number(), v.null()),
|
|
173
|
+
precision: v.union(v.number(), v.null()),
|
|
174
|
+
}),
|
|
175
|
+
breakdowns: v.object({
|
|
176
|
+
topSources: v.literal("eventVolume"),
|
|
177
|
+
topPages: v.literal("eventVolume"),
|
|
178
|
+
topCountries: v.literal("eventVolume"),
|
|
179
|
+
topDevices: v.literal("eventVolume"),
|
|
180
|
+
topBrowsers: v.literal("eventVolume"),
|
|
181
|
+
topEvents: v.literal("eventVolume"),
|
|
182
|
+
topGoals: v.literal("goalCompletionVolume"),
|
|
183
|
+
}),
|
|
184
|
+
coverage: resultCoverageValidator,
|
|
185
|
+
range: rangeSemanticsValidator,
|
|
186
|
+
features: v.object({
|
|
187
|
+
sessionReplay: v.literal("unsupported"),
|
|
188
|
+
errorInsights: v.literal("unsupported"),
|
|
189
|
+
outboundLinks: v.literal("eventVolume"),
|
|
190
|
+
botDetection: v.literal("unsupported"),
|
|
191
|
+
}),
|
|
192
|
+
}),
|
|
127
193
|
}),
|
|
128
194
|
handler: async (ctx, args) => {
|
|
129
195
|
const siteIds = validateSiteIds(args.siteIds);
|
|
@@ -145,6 +211,7 @@ export const overview = query({
|
|
|
145
211
|
fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
|
|
146
212
|
}
|
|
147
213
|
const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
|
|
214
|
+
requireAlignedRange(from, to, intervalMs, `${interval} overview`);
|
|
148
215
|
const firstBucket = Math.floor(from / intervalMs) * intervalMs;
|
|
149
216
|
const lastBucket = Math.floor(to / intervalMs) * intervalMs;
|
|
150
217
|
const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
|
|
@@ -159,7 +226,16 @@ export const overview = query({
|
|
|
159
226
|
}
|
|
160
227
|
const totals = emptyTotals();
|
|
161
228
|
const points = new Map<number, MetricTotals>();
|
|
229
|
+
const visitorSketch = createVisitorSketch();
|
|
230
|
+
let mergedVisitorSketch = visitorSketch;
|
|
231
|
+
const pointVisitorSketches = new Map<number, VisitorSketch>();
|
|
232
|
+
let visitorsComplete = true;
|
|
162
233
|
const dimensions = new Map<string, { value: string; count: number; revenueCents: number }>();
|
|
234
|
+
const overviewDataset = interval === "hour"
|
|
235
|
+
? "overviewHour" as const
|
|
236
|
+
: "overviewDay" as const;
|
|
237
|
+
|
|
238
|
+
await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
|
|
163
239
|
|
|
164
240
|
for (const siteId of siteIds) {
|
|
165
241
|
const buckets = await ctx.db
|
|
@@ -171,12 +247,29 @@ export const overview = query({
|
|
|
171
247
|
.gte("bucketStart", firstBucket)
|
|
172
248
|
.lte("bucketStart", lastBucket),
|
|
173
249
|
)
|
|
174
|
-
.take(perSiteLimit);
|
|
250
|
+
.take(perSiteLimit + 1);
|
|
251
|
+
failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
|
|
175
252
|
for (const bucket of buckets) {
|
|
176
253
|
addMetrics(totals, bucket);
|
|
177
254
|
const point = points.get(bucket.bucketStart) ?? emptyTotals();
|
|
178
255
|
addMetrics(point, bucket);
|
|
179
256
|
points.set(bucket.bucketStart, point);
|
|
257
|
+
if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
|
|
258
|
+
mergedVisitorSketch = unionVisitorSketches(
|
|
259
|
+
mergedVisitorSketch,
|
|
260
|
+
bucket.visitorSketch,
|
|
261
|
+
);
|
|
262
|
+
pointVisitorSketches.set(
|
|
263
|
+
bucket.bucketStart,
|
|
264
|
+
unionVisitorSketches(
|
|
265
|
+
pointVisitorSketches.get(bucket.bucketStart) ??
|
|
266
|
+
createVisitorSketch(bucket.visitorSketch.precision),
|
|
267
|
+
bucket.visitorSketch,
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
} else if (bucket.events > 0) {
|
|
271
|
+
visitorsComplete = false;
|
|
272
|
+
}
|
|
180
273
|
for (const dimension of bucket.dimensions) {
|
|
181
274
|
const key = `${dimension.type}:${dimension.value}`;
|
|
182
275
|
const current = dimensions.get(key) ?? {
|
|
@@ -197,6 +290,20 @@ export const overview = query({
|
|
|
197
290
|
.map(([, value]) => value)
|
|
198
291
|
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
|
|
199
292
|
.slice(0, 10);
|
|
293
|
+
if (visitorsComplete) {
|
|
294
|
+
totals.visitors = estimateVisitors(mergedVisitorSketch);
|
|
295
|
+
for (const [timestamp, point] of points) {
|
|
296
|
+
point.visitors = estimateVisitors(
|
|
297
|
+
pointVisitorSketches.get(timestamp) ?? createVisitorSketch(),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const coverage = await coverageForRange(ctx, siteIds, from, to, {
|
|
302
|
+
datasets: [overviewDataset],
|
|
303
|
+
complete: visitorsComplete,
|
|
304
|
+
});
|
|
305
|
+
const visitorBasis: "estimatedUnique" | "bucketOccurrences" =
|
|
306
|
+
visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
|
|
200
307
|
return {
|
|
201
308
|
from,
|
|
202
309
|
to,
|
|
@@ -213,6 +320,32 @@ export const overview = query({
|
|
|
213
320
|
topBrowsers: top("browser"),
|
|
214
321
|
topEvents: top("event"),
|
|
215
322
|
topGoals: top("goal"),
|
|
323
|
+
metadata: {
|
|
324
|
+
visitors: {
|
|
325
|
+
basis: visitorBasis,
|
|
326
|
+
exact: false,
|
|
327
|
+
legacyIncomplete: !visitorsComplete,
|
|
328
|
+
sketchVersion: visitorsComplete ? 1 : null,
|
|
329
|
+
precision: visitorsComplete ? 6 : null,
|
|
330
|
+
},
|
|
331
|
+
breakdowns: {
|
|
332
|
+
topSources: "eventVolume" as const,
|
|
333
|
+
topPages: "eventVolume" as const,
|
|
334
|
+
topCountries: "eventVolume" as const,
|
|
335
|
+
topDevices: "eventVolume" as const,
|
|
336
|
+
topBrowsers: "eventVolume" as const,
|
|
337
|
+
topEvents: "eventVolume" as const,
|
|
338
|
+
topGoals: "goalCompletionVolume" as const,
|
|
339
|
+
},
|
|
340
|
+
coverage,
|
|
341
|
+
range: rangeSemantics(from, to),
|
|
342
|
+
features: {
|
|
343
|
+
sessionReplay: "unsupported" as const,
|
|
344
|
+
errorInsights: "unsupported" as const,
|
|
345
|
+
outboundLinks: "eventVolume" as const,
|
|
346
|
+
botDetection: "unsupported" as const,
|
|
347
|
+
},
|
|
348
|
+
},
|
|
216
349
|
};
|
|
217
350
|
},
|
|
218
351
|
});
|
|
@@ -282,6 +415,7 @@ export const listSessions = query({
|
|
|
282
415
|
returns: paginationResultValidator(sessionDocumentValidator),
|
|
283
416
|
handler: async (ctx, args) => {
|
|
284
417
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
418
|
+
await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
|
|
285
419
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
286
420
|
kind: "sessions",
|
|
287
421
|
siteId: String(args.siteId),
|
|
@@ -331,7 +465,10 @@ export const listSessions = query({
|
|
|
331
465
|
.take(take)),
|
|
332
466
|
);
|
|
333
467
|
}
|
|
334
|
-
|
|
468
|
+
const publicRows = rows.map(
|
|
469
|
+
({ geoLookupAttemptedAt: _attemptedAt, ...row }) => row,
|
|
470
|
+
);
|
|
471
|
+
return keysetPaginationResult(publicRows, pagination, (row) => ({
|
|
335
472
|
timestamp: row.lastSeenAt,
|
|
336
473
|
creationTime: row._creationTime,
|
|
337
474
|
}));
|
|
@@ -349,6 +486,7 @@ export const sessionJourney = query({
|
|
|
349
486
|
returns: paginationResultValidator(eventDocumentValidator),
|
|
350
487
|
handler: async (ctx, args) => {
|
|
351
488
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
489
|
+
await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
|
|
352
490
|
const pagination = parseJourneyPagination(args.paginationOpts, {
|
|
353
491
|
kind: "events",
|
|
354
492
|
siteId: String(args.siteId),
|
|
@@ -379,6 +517,7 @@ export const listConversions = query({
|
|
|
379
517
|
returns: paginationResultValidator(conversionDocumentValidator),
|
|
380
518
|
handler: async (ctx, args) => {
|
|
381
519
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
520
|
+
await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
|
|
382
521
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
383
522
|
kind: "conversions",
|
|
384
523
|
siteId: String(args.siteId),
|
|
@@ -447,6 +586,7 @@ export const visitorJourney = query({
|
|
|
447
586
|
handler: async (ctx, args) => {
|
|
448
587
|
const siteIds = validateSiteIds(args.siteIds);
|
|
449
588
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
589
|
+
await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
|
|
450
590
|
const limit = args.limit ?? 200;
|
|
451
591
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
|
|
452
592
|
fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
|
|
@@ -482,36 +622,81 @@ export const goalsReport = query({
|
|
|
482
622
|
returns: v.object({
|
|
483
623
|
goals: v.array(goalReportItemValidator),
|
|
484
624
|
truncated: v.boolean(),
|
|
625
|
+
metadata: reportMetadataValidator,
|
|
485
626
|
}),
|
|
486
627
|
handler: async (ctx, args) => {
|
|
487
628
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
488
|
-
const
|
|
629
|
+
const goalRows = await ctx.db
|
|
489
630
|
.query("goals")
|
|
490
631
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
491
|
-
.take(MAX_GOALS_PER_SITE);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
495
|
-
range
|
|
496
|
-
.eq("siteId", args.siteId)
|
|
497
|
-
.gte("timestamp", args.from)
|
|
498
|
-
.lte("timestamp", args.to),
|
|
499
|
-
)
|
|
500
|
-
.take(5_001);
|
|
632
|
+
.take(MAX_GOALS_PER_SITE + 1);
|
|
633
|
+
failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
|
|
634
|
+
const goals = goalRows;
|
|
501
635
|
const totals = new Map<string, { completions: number; valueCents: number }>();
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
636
|
+
const useRollups = await dailyRollupsAvailable(
|
|
637
|
+
ctx,
|
|
638
|
+
args.siteId,
|
|
639
|
+
args.from,
|
|
640
|
+
args.to,
|
|
641
|
+
"goalRollups",
|
|
642
|
+
);
|
|
643
|
+
const rollups = useRollups
|
|
644
|
+
? await loadGoalDailyRollups(
|
|
645
|
+
ctx,
|
|
646
|
+
args.siteId,
|
|
647
|
+
goals.map((goal) => goal._id),
|
|
648
|
+
args.from,
|
|
649
|
+
args.to,
|
|
650
|
+
)
|
|
651
|
+
: null;
|
|
652
|
+
if (rollups) {
|
|
653
|
+
for (const row of rollups) {
|
|
654
|
+
const key = String(row.goalId);
|
|
655
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
656
|
+
total.completions += row.completions;
|
|
657
|
+
total.valueCents += row.valueCents;
|
|
658
|
+
totals.set(key, total);
|
|
659
|
+
}
|
|
660
|
+
} else {
|
|
661
|
+
await assertSourceAvailable(
|
|
662
|
+
ctx,
|
|
663
|
+
[args.siteId],
|
|
664
|
+
["goalCompletions"],
|
|
665
|
+
args.from,
|
|
666
|
+
);
|
|
667
|
+
const completions = await ctx.db
|
|
668
|
+
.query("goalCompletions")
|
|
669
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
670
|
+
range
|
|
671
|
+
.eq("siteId", args.siteId)
|
|
672
|
+
.gte("timestamp", args.from)
|
|
673
|
+
.lte("timestamp", args.to),
|
|
674
|
+
)
|
|
675
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
676
|
+
failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
|
|
677
|
+
for (const completion of completions) {
|
|
678
|
+
const key = String(completion.goalId);
|
|
679
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
680
|
+
total.completions += 1;
|
|
681
|
+
total.valueCents += completion.valueCents;
|
|
682
|
+
totals.set(key, total);
|
|
683
|
+
}
|
|
508
684
|
}
|
|
509
685
|
return {
|
|
510
686
|
goals: goals.map((goal) => ({
|
|
511
687
|
...goal,
|
|
512
688
|
...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
|
|
513
689
|
})),
|
|
514
|
-
truncated:
|
|
690
|
+
truncated: false,
|
|
691
|
+
metadata: {
|
|
692
|
+
basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
|
|
693
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
694
|
+
datasets: [rollups ? "goalRollups" : "goalCompletions"],
|
|
695
|
+
complete: true,
|
|
696
|
+
generation: rollups ? 1 : null,
|
|
697
|
+
}),
|
|
698
|
+
range: rangeSemantics(args.from, args.to),
|
|
699
|
+
},
|
|
515
700
|
};
|
|
516
701
|
},
|
|
517
702
|
});
|
|
@@ -523,6 +708,7 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
|
|
|
523
708
|
v.object({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() }),
|
|
524
709
|
),
|
|
525
710
|
truncated: v.boolean(),
|
|
711
|
+
metadata: reportMetadataValidator,
|
|
526
712
|
});
|
|
527
713
|
|
|
528
714
|
export const funnelsReport = query({
|
|
@@ -530,29 +716,74 @@ export const funnelsReport = query({
|
|
|
530
716
|
returns: v.array(funnelReportItemValidator),
|
|
531
717
|
handler: async (ctx, args) => {
|
|
532
718
|
validateRange(args.from, args.to, 90);
|
|
533
|
-
|
|
719
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
|
|
720
|
+
const funnelRows = await ctx.db
|
|
534
721
|
.query("funnels")
|
|
535
722
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
536
|
-
.take(MAX_FUNNELS_PER_SITE);
|
|
723
|
+
.take(MAX_FUNNELS_PER_SITE + 1);
|
|
724
|
+
failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
|
|
725
|
+
const funnels = funnelRows;
|
|
726
|
+
const useRollups = await dailyRollupsAvailable(
|
|
727
|
+
ctx,
|
|
728
|
+
args.siteId,
|
|
729
|
+
args.from,
|
|
730
|
+
args.to,
|
|
731
|
+
"funnelRollups",
|
|
732
|
+
);
|
|
733
|
+
const rollups = useRollups
|
|
734
|
+
? await loadFunnelDailyRollups(
|
|
735
|
+
ctx,
|
|
736
|
+
args.siteId,
|
|
737
|
+
funnels.map((funnel) => funnel._id),
|
|
738
|
+
args.from,
|
|
739
|
+
args.to,
|
|
740
|
+
)
|
|
741
|
+
: null;
|
|
742
|
+
if (!rollups) {
|
|
743
|
+
await assertSourceAvailable(
|
|
744
|
+
ctx,
|
|
745
|
+
[args.siteId],
|
|
746
|
+
["funnelSteps"],
|
|
747
|
+
args.from,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
const reportCoverage = await coverageForRange(
|
|
751
|
+
ctx,
|
|
752
|
+
[args.siteId],
|
|
753
|
+
args.from,
|
|
754
|
+
args.to,
|
|
755
|
+
{
|
|
756
|
+
datasets: [rollups ? "funnelRollups" : "funnelSteps"],
|
|
757
|
+
complete: true,
|
|
758
|
+
generation: rollups ? 1 : null,
|
|
759
|
+
},
|
|
760
|
+
);
|
|
537
761
|
const reports = [];
|
|
538
762
|
for (const funnel of funnels) {
|
|
539
|
-
const buckets = await ctx.db
|
|
540
|
-
.query("funnelStepBuckets")
|
|
541
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
|
|
542
|
-
range
|
|
543
|
-
.eq("siteId", args.siteId)
|
|
544
|
-
.eq("funnelId", funnel._id)
|
|
545
|
-
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
546
|
-
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
|
|
547
|
-
)
|
|
548
|
-
.take(601);
|
|
549
763
|
const stepsReport = funnel.steps.map((step, index) => ({
|
|
550
764
|
step: index,
|
|
551
765
|
name: step.name,
|
|
552
766
|
entrants: 0,
|
|
553
767
|
completions: 0,
|
|
554
768
|
}));
|
|
555
|
-
|
|
769
|
+
const buckets = rollups
|
|
770
|
+
? rollups
|
|
771
|
+
.filter((row) => row.funnelId === funnel._id)
|
|
772
|
+
.flatMap((row) => row.steps)
|
|
773
|
+
: await ctx.db
|
|
774
|
+
.query("funnelStepBuckets")
|
|
775
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
|
|
776
|
+
range
|
|
777
|
+
.eq("siteId", args.siteId)
|
|
778
|
+
.eq("funnelId", funnel._id)
|
|
779
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
780
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
|
|
781
|
+
)
|
|
782
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
783
|
+
if (!rollups) {
|
|
784
|
+
failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
|
|
785
|
+
}
|
|
786
|
+
for (const bucket of buckets) {
|
|
556
787
|
const step = stepsReport[bucket.step];
|
|
557
788
|
if (step) {
|
|
558
789
|
step.entrants += bucket.entrants;
|
|
@@ -562,7 +793,12 @@ export const funnelsReport = query({
|
|
|
562
793
|
reports.push({
|
|
563
794
|
...funnel,
|
|
564
795
|
stepsReport,
|
|
565
|
-
truncated:
|
|
796
|
+
truncated: false,
|
|
797
|
+
metadata: {
|
|
798
|
+
basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
|
|
799
|
+
coverage: reportCoverage,
|
|
800
|
+
range: rangeSemantics(args.from, args.to),
|
|
801
|
+
},
|
|
566
802
|
});
|
|
567
803
|
}
|
|
568
804
|
return reports;
|
|
@@ -584,64 +820,124 @@ export const affiliatesReport = query({
|
|
|
584
820
|
returns: v.object({
|
|
585
821
|
affiliates: v.array(affiliateReportItemValidator),
|
|
586
822
|
truncated: v.boolean(),
|
|
823
|
+
metadata: reportMetadataValidator.extend({
|
|
824
|
+
uniqueVisitors: v.object({
|
|
825
|
+
basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
|
|
826
|
+
exact: v.boolean(),
|
|
827
|
+
}),
|
|
828
|
+
}),
|
|
587
829
|
}),
|
|
588
830
|
handler: async (ctx, args) => {
|
|
589
831
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
832
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
|
|
590
833
|
const affiliateRows = await ctx.db
|
|
591
834
|
.query("affiliates")
|
|
592
835
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
593
836
|
.order("desc")
|
|
594
837
|
.take(MAX_AFFILIATES_PER_SITE + 1);
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
.
|
|
610
|
-
.
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
.take(5_001);
|
|
838
|
+
failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
|
|
839
|
+
const affiliates = affiliateRows;
|
|
840
|
+
const useRollups = await dailyRollupsAvailable(
|
|
841
|
+
ctx,
|
|
842
|
+
args.siteId,
|
|
843
|
+
args.from,
|
|
844
|
+
args.to,
|
|
845
|
+
"affiliateRollups",
|
|
846
|
+
);
|
|
847
|
+
const rollups = useRollups
|
|
848
|
+
? await loadAffiliateDailyRollups(
|
|
849
|
+
ctx,
|
|
850
|
+
args.siteId,
|
|
851
|
+
affiliates.map((affiliate) => affiliate._id),
|
|
852
|
+
args.from,
|
|
853
|
+
args.to,
|
|
854
|
+
)
|
|
855
|
+
: null;
|
|
614
856
|
const visits = new Map<string, {
|
|
615
857
|
visits: number;
|
|
616
858
|
visitors: Set<string>;
|
|
859
|
+
sketch: VisitorSketch;
|
|
617
860
|
}>();
|
|
618
|
-
for (const session of sessions.slice(0, 5_000)) {
|
|
619
|
-
if (!session.affiliateId) continue;
|
|
620
|
-
const key = String(session.affiliateId);
|
|
621
|
-
const total = visits.get(key) ?? {
|
|
622
|
-
visits: 0,
|
|
623
|
-
visitors: new Set<string>(),
|
|
624
|
-
};
|
|
625
|
-
total.visits += 1;
|
|
626
|
-
total.visitors.add(session.visitorId);
|
|
627
|
-
visits.set(key, total);
|
|
628
|
-
}
|
|
629
861
|
const totals = new Map<string, {
|
|
630
862
|
conversions: number;
|
|
631
863
|
revenueCents: number;
|
|
632
864
|
commissionCents: number;
|
|
633
865
|
}>();
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
866
|
+
if (rollups) {
|
|
867
|
+
for (const row of rollups) {
|
|
868
|
+
const key = String(row.affiliateId);
|
|
869
|
+
const traffic = visits.get(key) ?? {
|
|
870
|
+
visits: 0,
|
|
871
|
+
visitors: new Set<string>(),
|
|
872
|
+
sketch: createVisitorSketch(),
|
|
873
|
+
};
|
|
874
|
+
traffic.visits += row.visits;
|
|
875
|
+
traffic.sketch = unionVisitorSketches(
|
|
876
|
+
traffic.sketch,
|
|
877
|
+
row.visitorSketch,
|
|
878
|
+
);
|
|
879
|
+
visits.set(key, traffic);
|
|
880
|
+
const total = totals.get(key) ?? {
|
|
881
|
+
conversions: 0,
|
|
882
|
+
revenueCents: 0,
|
|
883
|
+
commissionCents: 0,
|
|
884
|
+
};
|
|
885
|
+
total.conversions += row.conversions;
|
|
886
|
+
total.revenueCents += row.revenueCents;
|
|
887
|
+
total.commissionCents += row.commissionCents;
|
|
888
|
+
totals.set(key, total);
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
891
|
+
await assertSourceAvailable(
|
|
892
|
+
ctx,
|
|
893
|
+
[args.siteId],
|
|
894
|
+
["affiliateStats", "sessions"],
|
|
895
|
+
args.from,
|
|
896
|
+
);
|
|
897
|
+
const stats = await ctx.db
|
|
898
|
+
.query("affiliateDailyStats")
|
|
899
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
900
|
+
range
|
|
901
|
+
.eq("siteId", args.siteId)
|
|
902
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
903
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
|
|
904
|
+
)
|
|
905
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
906
|
+
const sessions = await ctx.db
|
|
907
|
+
.query("sessions")
|
|
908
|
+
.withIndex("by_siteId_and_startedAt", (range) =>
|
|
909
|
+
range
|
|
910
|
+
.eq("siteId", args.siteId)
|
|
911
|
+
.gte("startedAt", args.from)
|
|
912
|
+
.lte("startedAt", args.to),
|
|
913
|
+
)
|
|
914
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
915
|
+
failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
|
|
916
|
+
failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
|
|
917
|
+
for (const session of sessions) {
|
|
918
|
+
if (!session.affiliateId) continue;
|
|
919
|
+
const key = String(session.affiliateId);
|
|
920
|
+
const total = visits.get(key) ?? {
|
|
921
|
+
visits: 0,
|
|
922
|
+
visitors: new Set<string>(),
|
|
923
|
+
sketch: createVisitorSketch(),
|
|
924
|
+
};
|
|
925
|
+
total.visits += 1;
|
|
926
|
+
total.visitors.add(session.visitorId);
|
|
927
|
+
visits.set(key, total);
|
|
928
|
+
}
|
|
929
|
+
for (const row of stats) {
|
|
930
|
+
const key = String(row.affiliateId);
|
|
931
|
+
const total = totals.get(key) ?? {
|
|
932
|
+
conversions: 0,
|
|
933
|
+
revenueCents: 0,
|
|
934
|
+
commissionCents: 0,
|
|
935
|
+
};
|
|
936
|
+
total.conversions += row.conversions;
|
|
937
|
+
total.revenueCents += row.revenueCents;
|
|
938
|
+
total.commissionCents += row.commissionCents;
|
|
939
|
+
totals.set(key, total);
|
|
940
|
+
}
|
|
645
941
|
}
|
|
646
942
|
return {
|
|
647
943
|
affiliates: affiliates.map((affiliate) => {
|
|
@@ -655,17 +951,498 @@ export const affiliatesReport = query({
|
|
|
655
951
|
...affiliate,
|
|
656
952
|
...performance,
|
|
657
953
|
visits: traffic?.visits ?? 0,
|
|
658
|
-
uniqueVisitors:
|
|
954
|
+
uniqueVisitors: rollups
|
|
955
|
+
? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
|
|
956
|
+
: traffic?.visitors.size ?? 0,
|
|
659
957
|
};
|
|
660
958
|
}),
|
|
661
|
-
truncated:
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
959
|
+
truncated: false,
|
|
960
|
+
metadata: {
|
|
961
|
+
basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
|
|
962
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
963
|
+
datasets: rollups
|
|
964
|
+
? ["affiliateRollups"]
|
|
965
|
+
: ["affiliateStats", "sessions"],
|
|
966
|
+
complete: true,
|
|
967
|
+
generation: rollups ? 1 : null,
|
|
968
|
+
}),
|
|
969
|
+
range: rangeSemantics(args.from, args.to),
|
|
970
|
+
uniqueVisitors: {
|
|
971
|
+
basis: rollups ? "estimatedUnique" as const : "exactLegacy" as const,
|
|
972
|
+
exact: !rollups,
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
const coverageDatasetValidator = v.union(
|
|
980
|
+
v.literal("overviewHour"),
|
|
981
|
+
v.literal("overviewDay"),
|
|
982
|
+
v.literal("events"),
|
|
983
|
+
v.literal("sessions"),
|
|
984
|
+
v.literal("conversions"),
|
|
985
|
+
v.literal("goals"),
|
|
986
|
+
v.literal("funnels"),
|
|
987
|
+
v.literal("affiliates"),
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
export const dataCoverage = query({
|
|
991
|
+
args: {
|
|
992
|
+
siteId: v.id("sites"),
|
|
993
|
+
from: v.number(),
|
|
994
|
+
to: v.number(),
|
|
995
|
+
},
|
|
996
|
+
returns: v.object({
|
|
997
|
+
siteId: v.id("sites"),
|
|
998
|
+
from: v.number(),
|
|
999
|
+
to: v.number(),
|
|
1000
|
+
datasets: v.array(v.object({
|
|
1001
|
+
dataset: coverageDatasetValidator,
|
|
1002
|
+
coverage: resultCoverageValidator,
|
|
1003
|
+
})),
|
|
1004
|
+
}),
|
|
1005
|
+
handler: async (ctx, args) => {
|
|
1006
|
+
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
1007
|
+
const datasets = [
|
|
1008
|
+
"overviewHour",
|
|
1009
|
+
"overviewDay",
|
|
1010
|
+
"events",
|
|
1011
|
+
"sessions",
|
|
1012
|
+
"conversions",
|
|
1013
|
+
"goals",
|
|
1014
|
+
"funnels",
|
|
1015
|
+
"affiliates",
|
|
1016
|
+
] as const;
|
|
1017
|
+
return {
|
|
1018
|
+
siteId: args.siteId,
|
|
1019
|
+
from: args.from,
|
|
1020
|
+
to: args.to,
|
|
1021
|
+
datasets: await Promise.all(datasets.map(async (dataset) => ({
|
|
1022
|
+
dataset,
|
|
1023
|
+
coverage: await datasetCoverageForRange(
|
|
1024
|
+
ctx,
|
|
1025
|
+
args.siteId,
|
|
1026
|
+
dataset,
|
|
1027
|
+
args.from,
|
|
1028
|
+
args.to,
|
|
1029
|
+
),
|
|
1030
|
+
}))),
|
|
665
1031
|
};
|
|
666
1032
|
},
|
|
667
1033
|
});
|
|
668
1034
|
|
|
1035
|
+
async function dailyRollupsAvailable(
|
|
1036
|
+
ctx: QueryCtx,
|
|
1037
|
+
siteId: Id<"sites">,
|
|
1038
|
+
from: number,
|
|
1039
|
+
to: number,
|
|
1040
|
+
dataset: "goalRollups" | "funnelRollups" | "affiliateRollups",
|
|
1041
|
+
) {
|
|
1042
|
+
if (!isAlignedRange(from, to, DAY_MS)) return false;
|
|
1043
|
+
const coverage = await ctx.db
|
|
1044
|
+
.query("analyticsCoverage")
|
|
1045
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1046
|
+
.unique();
|
|
1047
|
+
if (!coverage || coverage.rollupCompleteFrom > from) {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
const retainedBefore = coverage.retained.find(
|
|
1051
|
+
(item) => item.dataset === dataset,
|
|
1052
|
+
)?.before;
|
|
1053
|
+
return retainedBefore === undefined || from >= retainedBefore;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function loadGoalDailyRollups(
|
|
1057
|
+
ctx: QueryCtx,
|
|
1058
|
+
siteId: Id<"sites">,
|
|
1059
|
+
goalIds: Id<"goals">[],
|
|
1060
|
+
from: number,
|
|
1061
|
+
to: number,
|
|
1062
|
+
) {
|
|
1063
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1064
|
+
requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
|
|
1065
|
+
const rows: Doc<"goalDailyRollups">[] = [];
|
|
1066
|
+
for (const goalId of goalIds) {
|
|
1067
|
+
const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1068
|
+
const goalRows = await ctx.db
|
|
1069
|
+
.query("goalDailyRollups")
|
|
1070
|
+
.withIndex("by_siteId_and_goalId_and_bucketStart", (range) =>
|
|
1071
|
+
range
|
|
1072
|
+
.eq("siteId", siteId)
|
|
1073
|
+
.eq("goalId", goalId)
|
|
1074
|
+
.gte("bucketStart", from)
|
|
1075
|
+
.lte("bucketStart", to),
|
|
1076
|
+
)
|
|
1077
|
+
.take(perGoalLimit + 1);
|
|
1078
|
+
failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
|
|
1079
|
+
rows.push(...goalRows);
|
|
1080
|
+
await requireFeatureReadHeadroom(ctx, "goal daily rollups");
|
|
1081
|
+
}
|
|
1082
|
+
return rows;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async function loadFunnelDailyRollups(
|
|
1086
|
+
ctx: QueryCtx,
|
|
1087
|
+
siteId: Id<"sites">,
|
|
1088
|
+
funnelIds: Id<"funnels">[],
|
|
1089
|
+
from: number,
|
|
1090
|
+
to: number,
|
|
1091
|
+
) {
|
|
1092
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1093
|
+
requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
|
|
1094
|
+
const rows: Doc<"funnelDailyRollups">[] = [];
|
|
1095
|
+
for (const funnelId of funnelIds) {
|
|
1096
|
+
const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1097
|
+
const funnelRows = await ctx.db
|
|
1098
|
+
.query("funnelDailyRollups")
|
|
1099
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
|
|
1100
|
+
range
|
|
1101
|
+
.eq("siteId", siteId)
|
|
1102
|
+
.eq("funnelId", funnelId)
|
|
1103
|
+
.gte("bucketStart", from)
|
|
1104
|
+
.lte("bucketStart", to),
|
|
1105
|
+
)
|
|
1106
|
+
.take(perFunnelLimit + 1);
|
|
1107
|
+
failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
|
|
1108
|
+
rows.push(...funnelRows);
|
|
1109
|
+
await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
|
|
1110
|
+
}
|
|
1111
|
+
return rows;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function loadAffiliateDailyRollups(
|
|
1115
|
+
ctx: QueryCtx,
|
|
1116
|
+
siteId: Id<"sites">,
|
|
1117
|
+
affiliateIds: Id<"affiliates">[],
|
|
1118
|
+
from: number,
|
|
1119
|
+
to: number,
|
|
1120
|
+
) {
|
|
1121
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1122
|
+
requireFeatureRollupBudget(
|
|
1123
|
+
affiliateIds.length,
|
|
1124
|
+
days,
|
|
1125
|
+
"affiliate daily rollups",
|
|
1126
|
+
);
|
|
1127
|
+
const rows: Doc<"affiliateDailyRollups">[] = [];
|
|
1128
|
+
for (const affiliateId of affiliateIds) {
|
|
1129
|
+
const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1130
|
+
const affiliateRows = await ctx.db
|
|
1131
|
+
.query("affiliateDailyRollups")
|
|
1132
|
+
.withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) =>
|
|
1133
|
+
range
|
|
1134
|
+
.eq("siteId", siteId)
|
|
1135
|
+
.eq("affiliateId", affiliateId)
|
|
1136
|
+
.gte("bucketStart", from)
|
|
1137
|
+
.lte("bucketStart", to),
|
|
1138
|
+
)
|
|
1139
|
+
.take(perAffiliateLimit + 1);
|
|
1140
|
+
failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
|
|
1141
|
+
rows.push(...affiliateRows);
|
|
1142
|
+
await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
|
|
1143
|
+
}
|
|
1144
|
+
return rows;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function requireFeatureRollupBudget(
|
|
1148
|
+
entities: number,
|
|
1149
|
+
days: number,
|
|
1150
|
+
dataset: string,
|
|
1151
|
+
) {
|
|
1152
|
+
const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
|
|
1153
|
+
if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
|
|
1154
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
|
|
1155
|
+
dataset,
|
|
1156
|
+
maximumDays: MAX_FEATURE_ROLLUP_DAYS,
|
|
1157
|
+
maximumRows: MAX_FEATURE_ROLLUP_ROWS,
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function requireFeatureReadHeadroom(ctx: QueryCtx, dataset: string) {
|
|
1163
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
1164
|
+
if (
|
|
1165
|
+
metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
|
|
1166
|
+
metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE
|
|
1167
|
+
) {
|
|
1168
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
|
|
1169
|
+
dataset,
|
|
1170
|
+
bytesRead: metrics.bytesRead.used,
|
|
1171
|
+
documentsRead: metrics.documentsRead.used,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function failIfIncomplete<T>(rows: T[], maximum: number, dataset: string) {
|
|
1177
|
+
if (rows.length > maximum) {
|
|
1178
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
|
|
1179
|
+
maximumRows: maximum,
|
|
1180
|
+
dataset,
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async function coverageForRange(
|
|
1186
|
+
ctx: QueryCtx,
|
|
1187
|
+
siteIds: Id<"sites">[],
|
|
1188
|
+
from: number,
|
|
1189
|
+
to: number,
|
|
1190
|
+
options: {
|
|
1191
|
+
datasets: CoverageSource[];
|
|
1192
|
+
complete?: boolean;
|
|
1193
|
+
generation?: 1 | null;
|
|
1194
|
+
},
|
|
1195
|
+
) {
|
|
1196
|
+
const controls = await Promise.all(siteIds.map((siteId) =>
|
|
1197
|
+
ctx.db
|
|
1198
|
+
.query("analyticsCoverage")
|
|
1199
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1200
|
+
.unique()
|
|
1201
|
+
));
|
|
1202
|
+
const sourceRanges = await Promise.all(siteIds.map((siteId) =>
|
|
1203
|
+
sourceSetRange(ctx, siteId, options.datasets)
|
|
1204
|
+
));
|
|
1205
|
+
const retainedBefore = controls.reduce<number | null>((latest, control) => {
|
|
1206
|
+
const before = control?.retained
|
|
1207
|
+
.filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
|
|
1208
|
+
.reduce<number | undefined>(
|
|
1209
|
+
(latest, item) => Math.max(latest ?? 0, item.before),
|
|
1210
|
+
undefined,
|
|
1211
|
+
);
|
|
1212
|
+
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
1213
|
+
}, null);
|
|
1214
|
+
const availableFrom = options.generation === 1
|
|
1215
|
+
? controls.length > 0 && controls.every(Boolean)
|
|
1216
|
+
? Math.max(...controls.map((control) => control!.rollupCompleteFrom))
|
|
1217
|
+
: null
|
|
1218
|
+
: sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1219
|
+
? Math.max(...sourceRanges.map((range) => range!.from))
|
|
1220
|
+
: null;
|
|
1221
|
+
const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1222
|
+
? Math.min(...sourceRanges.map((range) => range!.through))
|
|
1223
|
+
: null;
|
|
1224
|
+
let state: "complete" | "partial" | "retained" | "unavailable";
|
|
1225
|
+
if (retainedBefore !== null && to < retainedBefore) {
|
|
1226
|
+
state = "unavailable";
|
|
1227
|
+
} else if (retainedBefore !== null && from < retainedBefore) {
|
|
1228
|
+
state = "partial";
|
|
1229
|
+
} else if (options.complete || (
|
|
1230
|
+
availableFrom !== null &&
|
|
1231
|
+
availableThrough !== null &&
|
|
1232
|
+
from >= availableFrom &&
|
|
1233
|
+
to <= availableThrough
|
|
1234
|
+
)) {
|
|
1235
|
+
state = "complete";
|
|
1236
|
+
} else if (sourceRanges.some(Boolean)) {
|
|
1237
|
+
state = "retained";
|
|
1238
|
+
} else {
|
|
1239
|
+
state = "unavailable";
|
|
1240
|
+
}
|
|
1241
|
+
return {
|
|
1242
|
+
state,
|
|
1243
|
+
generation: options.generation ?? null,
|
|
1244
|
+
availableFrom,
|
|
1245
|
+
availableThrough,
|
|
1246
|
+
retainedBefore,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async function assertSourceAvailable(
|
|
1251
|
+
ctx: QueryCtx,
|
|
1252
|
+
siteIds: Id<"sites">[],
|
|
1253
|
+
sources: CoverageSource[],
|
|
1254
|
+
from: number,
|
|
1255
|
+
) {
|
|
1256
|
+
const rows = await Promise.all(siteIds.map((siteId) =>
|
|
1257
|
+
ctx.db
|
|
1258
|
+
.query("analyticsCoverage")
|
|
1259
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1260
|
+
.unique()
|
|
1261
|
+
));
|
|
1262
|
+
const cutoff = rows.reduce<number | null>((latest, row) => {
|
|
1263
|
+
for (const retained of row?.retained ?? []) {
|
|
1264
|
+
if (sources.some((source) => source === retained.dataset)) {
|
|
1265
|
+
latest = Math.max(latest ?? 0, retained.before);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return latest;
|
|
1269
|
+
}, null);
|
|
1270
|
+
if (cutoff !== null && from < cutoff) {
|
|
1271
|
+
fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
|
|
1272
|
+
retainedBefore: cutoff,
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
async function datasetCoverageForRange(
|
|
1278
|
+
ctx: QueryCtx,
|
|
1279
|
+
siteId: Id<"sites">,
|
|
1280
|
+
dataset: PublicCoverageDataset,
|
|
1281
|
+
from: number,
|
|
1282
|
+
to: number,
|
|
1283
|
+
) {
|
|
1284
|
+
const sourceOptions: CoverageSource[][] = dataset === "goals"
|
|
1285
|
+
? [["goalRollups"], ["goalCompletions"]]
|
|
1286
|
+
: dataset === "funnels"
|
|
1287
|
+
? [["funnelRollups"], ["funnelSteps"]]
|
|
1288
|
+
: dataset === "affiliates"
|
|
1289
|
+
? [["affiliateRollups"], ["affiliateStats", "sessions"]]
|
|
1290
|
+
: [[dataset]];
|
|
1291
|
+
const options = (await Promise.all(sourceOptions.map(async (sources) => {
|
|
1292
|
+
const rollupDataset = sources[0] as
|
|
1293
|
+
| "goalRollups"
|
|
1294
|
+
| "funnelRollups"
|
|
1295
|
+
| "affiliateRollups";
|
|
1296
|
+
const isRollup = rollupDataset.endsWith("Rollups");
|
|
1297
|
+
const complete = isRollup
|
|
1298
|
+
? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
|
|
1299
|
+
: false;
|
|
1300
|
+
if (isRollup && !complete) return null;
|
|
1301
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1302
|
+
datasets: sources,
|
|
1303
|
+
complete,
|
|
1304
|
+
generation: complete ? 1 : null,
|
|
1305
|
+
});
|
|
1306
|
+
}))).filter((option): option is NonNullable<typeof option> => option !== null);
|
|
1307
|
+
return options.sort((left, right) =>
|
|
1308
|
+
coverageRank(right.state) - coverageRank(left.state)
|
|
1309
|
+
)[0]!;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
type PublicCoverageDataset =
|
|
1313
|
+
| "overviewHour"
|
|
1314
|
+
| "overviewDay"
|
|
1315
|
+
| "events"
|
|
1316
|
+
| "sessions"
|
|
1317
|
+
| "conversions"
|
|
1318
|
+
| "goals"
|
|
1319
|
+
| "funnels"
|
|
1320
|
+
| "affiliates";
|
|
1321
|
+
|
|
1322
|
+
type CoverageSource =
|
|
1323
|
+
| "overviewHour"
|
|
1324
|
+
| "overviewDay"
|
|
1325
|
+
| "events"
|
|
1326
|
+
| "sessions"
|
|
1327
|
+
| "conversions"
|
|
1328
|
+
| "goalCompletions"
|
|
1329
|
+
| "funnelSteps"
|
|
1330
|
+
| "affiliateStats"
|
|
1331
|
+
| "goalRollups"
|
|
1332
|
+
| "funnelRollups"
|
|
1333
|
+
| "affiliateRollups";
|
|
1334
|
+
|
|
1335
|
+
type SourceRange = { from: number; through: number };
|
|
1336
|
+
|
|
1337
|
+
async function sourceSetRange(
|
|
1338
|
+
ctx: QueryCtx,
|
|
1339
|
+
siteId: Id<"sites">,
|
|
1340
|
+
sources: CoverageSource[],
|
|
1341
|
+
): Promise<SourceRange | null> {
|
|
1342
|
+
const ranges = (await Promise.all(
|
|
1343
|
+
sources.map((source) => sourceRange(ctx, siteId, source)),
|
|
1344
|
+
)).filter((range): range is SourceRange => range !== null);
|
|
1345
|
+
if (ranges.length === 0) return null;
|
|
1346
|
+
return {
|
|
1347
|
+
from: Math.min(...ranges.map((range) => range.from)),
|
|
1348
|
+
through: Math.max(...ranges.map((range) => range.through)),
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
async function sourceRange(
|
|
1353
|
+
ctx: QueryCtx,
|
|
1354
|
+
siteId: Id<"sites">,
|
|
1355
|
+
source: CoverageSource,
|
|
1356
|
+
): Promise<SourceRange | null> {
|
|
1357
|
+
if (source === "overviewHour" || source === "overviewDay") {
|
|
1358
|
+
const granularity = source === "overviewHour" ? "hour" as const : "day" as const;
|
|
1359
|
+
const [first, last] = await Promise.all([
|
|
1360
|
+
ctx.db.query("aggregateBuckets")
|
|
1361
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
1362
|
+
range.eq("siteId", siteId).eq("granularity", granularity),
|
|
1363
|
+
).order("asc").take(1),
|
|
1364
|
+
ctx.db.query("aggregateBuckets")
|
|
1365
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
1366
|
+
range.eq("siteId", siteId).eq("granularity", granularity),
|
|
1367
|
+
).order("desc").take(1),
|
|
1368
|
+
]);
|
|
1369
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1370
|
+
}
|
|
1371
|
+
if (source === "events") {
|
|
1372
|
+
const [first, last] = await Promise.all([
|
|
1373
|
+
ctx.db.query("eventBatches")
|
|
1374
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1375
|
+
.order("asc").take(1),
|
|
1376
|
+
ctx.db.query("eventBatches")
|
|
1377
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1378
|
+
.order("desc").take(1),
|
|
1379
|
+
]);
|
|
1380
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1381
|
+
}
|
|
1382
|
+
if (source === "sessions") {
|
|
1383
|
+
const [first, last] = await Promise.all([
|
|
1384
|
+
ctx.db.query("sessions")
|
|
1385
|
+
.withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
|
|
1386
|
+
.order("asc").take(1),
|
|
1387
|
+
ctx.db.query("sessions")
|
|
1388
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
|
|
1389
|
+
.order("desc").take(1),
|
|
1390
|
+
]);
|
|
1391
|
+
return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
|
|
1392
|
+
}
|
|
1393
|
+
if (source === "conversions") {
|
|
1394
|
+
const [first, last] = await Promise.all([
|
|
1395
|
+
ctx.db.query("conversions")
|
|
1396
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1397
|
+
.order("asc").take(1),
|
|
1398
|
+
ctx.db.query("conversions")
|
|
1399
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1400
|
+
.order("desc").take(1),
|
|
1401
|
+
]);
|
|
1402
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1403
|
+
}
|
|
1404
|
+
if (source === "goalCompletions") {
|
|
1405
|
+
const [first, last] = await Promise.all([
|
|
1406
|
+
ctx.db.query("goalCompletions")
|
|
1407
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1408
|
+
.order("asc").take(1),
|
|
1409
|
+
ctx.db.query("goalCompletions")
|
|
1410
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1411
|
+
.order("desc").take(1),
|
|
1412
|
+
]);
|
|
1413
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1414
|
+
}
|
|
1415
|
+
const table = source === "funnelSteps"
|
|
1416
|
+
? "funnelStepBuckets" as const
|
|
1417
|
+
: source === "affiliateStats"
|
|
1418
|
+
? "affiliateDailyStats" as const
|
|
1419
|
+
: source === "goalRollups"
|
|
1420
|
+
? "goalDailyRollups" as const
|
|
1421
|
+
: source === "funnelRollups"
|
|
1422
|
+
? "funnelDailyRollups" as const
|
|
1423
|
+
: "affiliateDailyRollups" as const;
|
|
1424
|
+
const [first, last] = await Promise.all([
|
|
1425
|
+
ctx.db.query(table)
|
|
1426
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1427
|
+
.order("asc").take(1),
|
|
1428
|
+
ctx.db.query(table)
|
|
1429
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1430
|
+
.order("desc").take(1),
|
|
1431
|
+
]);
|
|
1432
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function bounds(from: number | undefined, through: number | undefined) {
|
|
1436
|
+
return from === undefined || through === undefined ? null : { from, through };
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function coverageRank(state: "complete" | "partial" | "retained" | "unavailable") {
|
|
1440
|
+
if (state === "complete") return 3;
|
|
1441
|
+
if (state === "retained") return 2;
|
|
1442
|
+
if (state === "partial") return 1;
|
|
1443
|
+
return 0;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
669
1446
|
|
|
670
1447
|
type MetricTotals = {
|
|
671
1448
|
events: number;
|
|
@@ -715,15 +1492,46 @@ function validateSiteIds<T>(siteIds: T[]): T[] {
|
|
|
715
1492
|
|
|
716
1493
|
function validateRange(from: number, to: number, maxDays: number) {
|
|
717
1494
|
if (
|
|
718
|
-
!Number.
|
|
719
|
-
!Number.
|
|
1495
|
+
!Number.isSafeInteger(from) ||
|
|
1496
|
+
!Number.isSafeInteger(to) ||
|
|
720
1497
|
from < 0 ||
|
|
721
|
-
to
|
|
722
|
-
to - from
|
|
1498
|
+
to < from ||
|
|
1499
|
+
to - from >= maxDays * DAY_MS
|
|
723
1500
|
) {
|
|
724
|
-
fail(
|
|
1501
|
+
fail(
|
|
1502
|
+
"INVALID_ARGUMENT",
|
|
1503
|
+
`range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`,
|
|
1504
|
+
);
|
|
725
1505
|
}
|
|
726
|
-
return { from
|
|
1506
|
+
return { from, to };
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function isAlignedRange(from: number, to: number, intervalMs: number) {
|
|
1510
|
+
return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function requireAlignedRange(
|
|
1514
|
+
from: number,
|
|
1515
|
+
to: number,
|
|
1516
|
+
intervalMs: number,
|
|
1517
|
+
report: string,
|
|
1518
|
+
) {
|
|
1519
|
+
if (!isAlignedRange(from, to, intervalMs)) {
|
|
1520
|
+
fail(
|
|
1521
|
+
"REPORT_INCOMPLETE",
|
|
1522
|
+
`${report} requires complete inclusive UTC bucket boundaries`,
|
|
1523
|
+
{ from, to, intervalMs, timezone: "UTC" },
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function rangeSemantics(from: number, to: number) {
|
|
1529
|
+
return {
|
|
1530
|
+
from,
|
|
1531
|
+
to,
|
|
1532
|
+
boundary: "inclusive" as const,
|
|
1533
|
+
timezone: "UTC" as const,
|
|
1534
|
+
};
|
|
727
1535
|
}
|
|
728
1536
|
|
|
729
1537
|
type KeysetPaginationInput = {
|