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