@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
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { paginationOptsValidator, paginationResultValidator, } from "convex/server";
|
|
2
2
|
import { v } from "convex/values";
|
|
3
3
|
import { query } from "./_generated/server.js";
|
|
4
|
-
import { AGGREGATE_SHARDS, DAY_MS, HOUR_MS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS,
|
|
4
|
+
import { AGGREGATE_SHARDS, DAY_MS, FEATURE_REPORT_BYTE_RESERVE, FEATURE_REPORT_DOCUMENT_RESERVE, HOUR_MS, HOURLY_AGGREGATE_SHARDS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_FEATURE_ROLLUP_DAYS, MAX_FEATURE_ROLLUP_ROWS, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, MAX_OVERVIEW_AGGREGATE_ROWS, MAX_REPORT_BUCKETS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, MAX_SITE_MAP_ROUTES_PER_DAY, MAX_SITE_MAP_ROWS, MAX_VITALS_REPORT_ROWS, REPORT_ROLLUP_SHARDS, ROUTE_TRANSITION_SLOTS, SITE_MAP_OTHER_ROUTE, VITALS_REPORT_PAGE_LIMIT, } from "./constants.js";
|
|
5
|
+
import { createVisitorSketch, estimateVisitors, unionVisitorSketches, } from "./cardinality.js";
|
|
5
6
|
import { fail } from "./errors.js";
|
|
7
|
+
import { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, loadVisitorJourneyEvents, } from "./eventStore.js";
|
|
6
8
|
import { isPlainRecord } from "./guards.js";
|
|
7
|
-
import { affiliateFieldsValidator, eventFieldsValidator, funnelFieldsValidator, goalFieldsValidator, sessionFieldsValidator, } from "./validators.js";
|
|
9
|
+
import { affiliateFieldsValidator, eventFieldsValidator, funnelFieldsValidator, goalFieldsValidator, sessionFieldsValidator, vitalMetricValidator, } from "./validators.js";
|
|
10
|
+
import { resolveVisitorIdentities } from "./visitors.js";
|
|
11
|
+
import { mergeVitalHistograms, VITAL_ALL, VITAL_METRICS, vitalHistogramPercentile, vitalRatingCounts, } from "./vitals.js";
|
|
8
12
|
const sessionDocumentValidator = sessionFieldsValidator.extend({
|
|
9
13
|
_id: v.id("sessions"),
|
|
10
14
|
_creationTime: v.number(),
|
|
11
15
|
});
|
|
12
16
|
const eventDocumentValidator = eventFieldsValidator.extend({
|
|
13
|
-
_id: v.
|
|
17
|
+
_id: v.string(),
|
|
14
18
|
_creationTime: v.number(),
|
|
15
19
|
});
|
|
16
20
|
const liveSessionDocumentValidator = v.object({
|
|
@@ -33,6 +37,8 @@ const liveSessionDocumentValidator = v.object({
|
|
|
33
37
|
device: v.string(),
|
|
34
38
|
pageviewCount: v.number(),
|
|
35
39
|
eventCount: v.number(),
|
|
40
|
+
previousPath: v.optional(v.string()),
|
|
41
|
+
movedAt: v.optional(v.number()),
|
|
36
42
|
});
|
|
37
43
|
const conversionDocumentValidator = v.object({
|
|
38
44
|
_id: v.id("conversions"),
|
|
@@ -64,12 +70,33 @@ const metricTotalsValidator = v.object({
|
|
|
64
70
|
conversions: v.number(),
|
|
65
71
|
revenueCents: v.number(),
|
|
66
72
|
});
|
|
67
|
-
const timePointValidator = metricTotalsValidator.extend({
|
|
73
|
+
const timePointValidator = metricTotalsValidator.extend({
|
|
74
|
+
timestamp: v.number(),
|
|
75
|
+
});
|
|
68
76
|
const topItemValidator = v.object({
|
|
69
77
|
value: v.string(),
|
|
70
78
|
count: v.number(),
|
|
71
79
|
revenueCents: v.number(),
|
|
72
80
|
});
|
|
81
|
+
const coverageStateValidator = v.union(v.literal("complete"), v.literal("partial"), v.literal("retained"), v.literal("unavailable"));
|
|
82
|
+
const resultCoverageValidator = v.object({
|
|
83
|
+
state: coverageStateValidator,
|
|
84
|
+
generation: v.union(v.number(), v.null()),
|
|
85
|
+
availableFrom: v.union(v.number(), v.null()),
|
|
86
|
+
availableThrough: v.union(v.number(), v.null()),
|
|
87
|
+
retainedBefore: v.union(v.number(), v.null()),
|
|
88
|
+
});
|
|
89
|
+
const rangeSemanticsValidator = v.object({
|
|
90
|
+
from: v.number(),
|
|
91
|
+
to: v.number(),
|
|
92
|
+
boundary: v.literal("inclusive"),
|
|
93
|
+
timezone: v.literal("UTC"),
|
|
94
|
+
});
|
|
95
|
+
const reportMetadataValidator = v.object({
|
|
96
|
+
basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
|
|
97
|
+
coverage: resultCoverageValidator,
|
|
98
|
+
range: rangeSemanticsValidator,
|
|
99
|
+
});
|
|
73
100
|
export const overview = query({
|
|
74
101
|
args: {
|
|
75
102
|
siteIds: v.array(v.id("sites")),
|
|
@@ -85,12 +112,40 @@ export const overview = query({
|
|
|
85
112
|
totals: metricTotalsValidator,
|
|
86
113
|
timeSeries: v.array(timePointValidator),
|
|
87
114
|
topSources: v.array(topItemValidator),
|
|
115
|
+
topCampaigns: v.array(topItemValidator),
|
|
88
116
|
topPages: v.array(topItemValidator),
|
|
89
117
|
topCountries: v.array(topItemValidator),
|
|
90
118
|
topDevices: v.array(topItemValidator),
|
|
91
119
|
topBrowsers: v.array(topItemValidator),
|
|
92
120
|
topEvents: v.array(topItemValidator),
|
|
93
121
|
topGoals: v.array(topItemValidator),
|
|
122
|
+
metadata: v.object({
|
|
123
|
+
visitors: v.object({
|
|
124
|
+
basis: v.union(v.literal("estimatedUnique"), v.literal("bucketOccurrences")),
|
|
125
|
+
exact: v.boolean(),
|
|
126
|
+
legacyIncomplete: v.boolean(),
|
|
127
|
+
sketchVersion: v.union(v.number(), v.null()),
|
|
128
|
+
precision: v.union(v.number(), v.null()),
|
|
129
|
+
}),
|
|
130
|
+
breakdowns: v.object({
|
|
131
|
+
topSources: v.literal("eventVolume"),
|
|
132
|
+
topCampaigns: v.literal("eventVolume"),
|
|
133
|
+
topPages: v.literal("eventVolume"),
|
|
134
|
+
topCountries: v.literal("eventVolume"),
|
|
135
|
+
topDevices: v.literal("eventVolume"),
|
|
136
|
+
topBrowsers: v.literal("eventVolume"),
|
|
137
|
+
topEvents: v.literal("eventVolume"),
|
|
138
|
+
topGoals: v.literal("goalCompletionVolume"),
|
|
139
|
+
}),
|
|
140
|
+
coverage: resultCoverageValidator,
|
|
141
|
+
range: rangeSemanticsValidator,
|
|
142
|
+
features: v.object({
|
|
143
|
+
sessionReplay: v.literal("unsupported"),
|
|
144
|
+
errorInsights: v.literal("unsupported"),
|
|
145
|
+
outboundLinks: v.literal("eventVolume"),
|
|
146
|
+
botDetection: v.literal("userAgent"),
|
|
147
|
+
}),
|
|
148
|
+
}),
|
|
94
149
|
}),
|
|
95
150
|
handler: async (ctx, args) => {
|
|
96
151
|
const siteIds = validateSiteIds(args.siteIds);
|
|
@@ -108,21 +163,31 @@ export const overview = query({
|
|
|
108
163
|
}
|
|
109
164
|
const currency = [...currencies][0];
|
|
110
165
|
const { from, to } = validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
111
|
-
const interval = args.interval ?? (to - from <=
|
|
112
|
-
if (interval === "hour" && to - from >
|
|
113
|
-
fail("INVALID_ARGUMENT", "hourly reports are limited to
|
|
166
|
+
const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
|
|
167
|
+
if (interval === "hour" && to - from > DAY_MS) {
|
|
168
|
+
fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
|
|
114
169
|
}
|
|
115
170
|
const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
|
|
171
|
+
requireAlignedRange(from, to, intervalMs, `${interval} overview`);
|
|
116
172
|
const firstBucket = Math.floor(from / intervalMs) * intervalMs;
|
|
117
173
|
const lastBucket = Math.floor(to / intervalMs) * intervalMs;
|
|
118
174
|
const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
|
|
119
|
-
const perSiteLimit = bucketCount *
|
|
175
|
+
const perSiteLimit = bucketCount *
|
|
176
|
+
(interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
120
177
|
if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
|
|
121
178
|
fail("LIMIT_EXCEEDED", "overview range is too wide for the requested number of sites", { maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS });
|
|
122
179
|
}
|
|
123
180
|
const totals = emptyTotals();
|
|
124
181
|
const points = new Map();
|
|
182
|
+
const visitorSketch = createVisitorSketch();
|
|
183
|
+
let mergedVisitorSketch = visitorSketch;
|
|
184
|
+
const pointVisitorSketches = new Map();
|
|
185
|
+
let visitorsComplete = true;
|
|
125
186
|
const dimensions = new Map();
|
|
187
|
+
const overviewDataset = interval === "hour"
|
|
188
|
+
? "overviewHour"
|
|
189
|
+
: "overviewDay";
|
|
190
|
+
await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
|
|
126
191
|
for (const siteId of siteIds) {
|
|
127
192
|
const buckets = await ctx.db
|
|
128
193
|
.query("aggregateBuckets")
|
|
@@ -131,12 +196,21 @@ export const overview = query({
|
|
|
131
196
|
.eq("granularity", interval)
|
|
132
197
|
.gte("bucketStart", firstBucket)
|
|
133
198
|
.lte("bucketStart", lastBucket))
|
|
134
|
-
.take(perSiteLimit);
|
|
199
|
+
.take(perSiteLimit + 1);
|
|
200
|
+
failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
|
|
135
201
|
for (const bucket of buckets) {
|
|
136
202
|
addMetrics(totals, bucket);
|
|
137
203
|
const point = points.get(bucket.bucketStart) ?? emptyTotals();
|
|
138
204
|
addMetrics(point, bucket);
|
|
139
205
|
points.set(bucket.bucketStart, point);
|
|
206
|
+
if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
|
|
207
|
+
mergedVisitorSketch = unionVisitorSketches(mergedVisitorSketch, bucket.visitorSketch);
|
|
208
|
+
pointVisitorSketches.set(bucket.bucketStart, unionVisitorSketches(pointVisitorSketches.get(bucket.bucketStart) ??
|
|
209
|
+
createVisitorSketch(bucket.visitorSketch.precision), bucket.visitorSketch));
|
|
210
|
+
}
|
|
211
|
+
else if (bucket.events > 0) {
|
|
212
|
+
visitorsComplete = false;
|
|
213
|
+
}
|
|
140
214
|
for (const dimension of bucket.dimensions) {
|
|
141
215
|
const key = `${dimension.type}:${dimension.value}`;
|
|
142
216
|
const current = dimensions.get(key) ?? {
|
|
@@ -155,6 +229,17 @@ export const overview = query({
|
|
|
155
229
|
.map(([, value]) => value)
|
|
156
230
|
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
|
|
157
231
|
.slice(0, 10);
|
|
232
|
+
if (visitorsComplete) {
|
|
233
|
+
totals.visitors = estimateVisitors(mergedVisitorSketch);
|
|
234
|
+
for (const [timestamp, point] of points) {
|
|
235
|
+
point.visitors = estimateVisitors(pointVisitorSketches.get(timestamp) ?? createVisitorSketch());
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const coverage = await coverageForRange(ctx, siteIds, from, to, {
|
|
239
|
+
datasets: [overviewDataset],
|
|
240
|
+
complete: visitorsComplete,
|
|
241
|
+
});
|
|
242
|
+
const visitorBasis = visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
|
|
158
243
|
return {
|
|
159
244
|
from,
|
|
160
245
|
to,
|
|
@@ -165,12 +250,40 @@ export const overview = query({
|
|
|
165
250
|
.sort(([left], [right]) => left - right)
|
|
166
251
|
.map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
|
|
167
252
|
topSources: top("source"),
|
|
253
|
+
topCampaigns: top("campaign"),
|
|
168
254
|
topPages: top("page"),
|
|
169
255
|
topCountries: top("country"),
|
|
170
256
|
topDevices: top("device"),
|
|
171
257
|
topBrowsers: top("browser"),
|
|
172
258
|
topEvents: top("event"),
|
|
173
259
|
topGoals: top("goal"),
|
|
260
|
+
metadata: {
|
|
261
|
+
visitors: {
|
|
262
|
+
basis: visitorBasis,
|
|
263
|
+
exact: false,
|
|
264
|
+
legacyIncomplete: !visitorsComplete,
|
|
265
|
+
sketchVersion: visitorsComplete ? 1 : null,
|
|
266
|
+
precision: visitorsComplete ? 6 : null,
|
|
267
|
+
},
|
|
268
|
+
breakdowns: {
|
|
269
|
+
topSources: "eventVolume",
|
|
270
|
+
topCampaigns: "eventVolume",
|
|
271
|
+
topPages: "eventVolume",
|
|
272
|
+
topCountries: "eventVolume",
|
|
273
|
+
topDevices: "eventVolume",
|
|
274
|
+
topBrowsers: "eventVolume",
|
|
275
|
+
topEvents: "eventVolume",
|
|
276
|
+
topGoals: "goalCompletionVolume",
|
|
277
|
+
},
|
|
278
|
+
coverage,
|
|
279
|
+
range: rangeSemantics(from, to),
|
|
280
|
+
features: {
|
|
281
|
+
sessionReplay: "unsupported",
|
|
282
|
+
errorInsights: "unsupported",
|
|
283
|
+
outboundLinks: "eventVolume",
|
|
284
|
+
botDetection: "userAgent",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
174
287
|
};
|
|
175
288
|
},
|
|
176
289
|
});
|
|
@@ -184,7 +297,9 @@ export const liveVisitors = query({
|
|
|
184
297
|
handler: async (ctx, args) => {
|
|
185
298
|
const siteIds = validateSiteIds(args.siteIds);
|
|
186
299
|
const limit = args.limit ?? 100;
|
|
187
|
-
if (!Number.isSafeInteger(limit) ||
|
|
300
|
+
if (!Number.isSafeInteger(limit) ||
|
|
301
|
+
limit < 1 ||
|
|
302
|
+
limit > MAX_LIVE_VISITORS) {
|
|
188
303
|
fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`);
|
|
189
304
|
}
|
|
190
305
|
if (!Number.isFinite(args.now) || args.now < 0) {
|
|
@@ -221,9 +336,241 @@ export const liveVisitors = query({
|
|
|
221
336
|
device: row.device,
|
|
222
337
|
pageviewCount: row.pageviewCount,
|
|
223
338
|
eventCount: row.eventCount,
|
|
339
|
+
...(row.previousPath ? { previousPath: row.previousPath } : {}),
|
|
340
|
+
...(row.movedAt !== undefined ? { movedAt: row.movedAt } : {}),
|
|
224
341
|
}));
|
|
225
342
|
},
|
|
226
343
|
});
|
|
344
|
+
const siteMapRouteValidator = v.object({
|
|
345
|
+
route: v.string(),
|
|
346
|
+
pageviews: v.number(),
|
|
347
|
+
visitors: v.number(),
|
|
348
|
+
entries: v.number(),
|
|
349
|
+
exits: v.number(),
|
|
350
|
+
bounces: v.number(),
|
|
351
|
+
transitionsIn: v.number(),
|
|
352
|
+
transitionsOut: v.number(),
|
|
353
|
+
dwellMs: v.number(),
|
|
354
|
+
dwellSamples: v.number(),
|
|
355
|
+
daily: v.array(v.object({ bucketStart: v.number(), pageviews: v.number() })),
|
|
356
|
+
});
|
|
357
|
+
const siteMapTransitionValidator = v.object({
|
|
358
|
+
from: v.string(),
|
|
359
|
+
to: v.string(),
|
|
360
|
+
count: v.number(),
|
|
361
|
+
});
|
|
362
|
+
const siteMapCoverageDataset = "siteMapRollups";
|
|
363
|
+
/**
|
|
364
|
+
* Every route the sites served in the range and the transitions between
|
|
365
|
+
* them, read from the daily route rollups newest day first. Exits and
|
|
366
|
+
* bounces are derived rather than counted: a session enters once, leaves
|
|
367
|
+
* once, and every later pageview is a transition, so per route
|
|
368
|
+
* `exits = entries + transitionsIn - transitionsOut` and
|
|
369
|
+
* `bounces = entries - continued` hold without a session-end hook. The
|
|
370
|
+
* report never falls back to raw telemetry; days it could not cover are
|
|
371
|
+
* declared through `coverage` and `window`.
|
|
372
|
+
*/
|
|
373
|
+
export const siteMap = query({
|
|
374
|
+
args: {
|
|
375
|
+
siteIds: v.array(v.id("sites")),
|
|
376
|
+
from: v.number(),
|
|
377
|
+
to: v.number(),
|
|
378
|
+
},
|
|
379
|
+
returns: v.object({
|
|
380
|
+
from: v.number(),
|
|
381
|
+
to: v.number(),
|
|
382
|
+
window: v.union(v.object({ from: v.number(), to: v.number() }), v.null()),
|
|
383
|
+
routes: v.array(siteMapRouteValidator),
|
|
384
|
+
transitions: v.array(siteMapTransitionValidator),
|
|
385
|
+
truncated: v.object({ routes: v.boolean(), transitions: v.boolean() }),
|
|
386
|
+
metadata: reportMetadataValidator.extend({
|
|
387
|
+
visitors: v.object({
|
|
388
|
+
basis: v.literal("estimatedUnique"),
|
|
389
|
+
exact: v.boolean(),
|
|
390
|
+
}),
|
|
391
|
+
limits: v.object({
|
|
392
|
+
routesPerDay: v.number(),
|
|
393
|
+
transitionSlots: v.number(),
|
|
394
|
+
rows: v.number(),
|
|
395
|
+
}),
|
|
396
|
+
}),
|
|
397
|
+
}),
|
|
398
|
+
handler: async (ctx, args) => {
|
|
399
|
+
const siteIds = validateSiteIds(args.siteIds);
|
|
400
|
+
validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
|
|
401
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "site map");
|
|
402
|
+
const controls = await Promise.all(siteIds.map((siteId) => ctx.db
|
|
403
|
+
.query("analyticsCoverage")
|
|
404
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
405
|
+
.unique()));
|
|
406
|
+
const since = controls.every((control) => control?.siteMapSince !== undefined)
|
|
407
|
+
? Math.max(...controls.map((control) => control.siteMapSince))
|
|
408
|
+
: null;
|
|
409
|
+
const retainedBefore = controls.reduce((latest, control) => {
|
|
410
|
+
const before = control?.retained.find((item) => item.dataset === siteMapCoverageDataset)?.before;
|
|
411
|
+
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
412
|
+
}, null);
|
|
413
|
+
const metadata = (state) => ({
|
|
414
|
+
basis: "dailyRollup",
|
|
415
|
+
coverage: {
|
|
416
|
+
state,
|
|
417
|
+
generation: 1,
|
|
418
|
+
availableFrom: since,
|
|
419
|
+
availableThrough: since === null ? null : args.to,
|
|
420
|
+
retainedBefore,
|
|
421
|
+
},
|
|
422
|
+
range: rangeSemantics(args.from, args.to),
|
|
423
|
+
visitors: { basis: "estimatedUnique", exact: false },
|
|
424
|
+
limits: {
|
|
425
|
+
routesPerDay: MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
426
|
+
transitionSlots: ROUTE_TRANSITION_SLOTS,
|
|
427
|
+
rows: MAX_SITE_MAP_ROWS,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
if (since === null ||
|
|
431
|
+
args.to < since ||
|
|
432
|
+
(retainedBefore !== null && args.to < retainedBefore)) {
|
|
433
|
+
return {
|
|
434
|
+
from: args.from,
|
|
435
|
+
to: args.to,
|
|
436
|
+
window: null,
|
|
437
|
+
routes: [],
|
|
438
|
+
transitions: [],
|
|
439
|
+
truncated: { routes: false, transitions: false },
|
|
440
|
+
metadata: metadata("unavailable"),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const readFrom = Math.max(args.from, Math.floor(since / DAY_MS) * DAY_MS, retainedBefore ?? 0);
|
|
444
|
+
let budget = MAX_SITE_MAP_ROWS;
|
|
445
|
+
let completeFrom = readFrom;
|
|
446
|
+
const rows = [];
|
|
447
|
+
for (const siteId of siteIds) {
|
|
448
|
+
const read = await readRouteRollups(ctx, siteId, readFrom, args.to, budget);
|
|
449
|
+
budget -= read.rows.length;
|
|
450
|
+
completeFrom = Math.max(completeFrom, read.completeFrom);
|
|
451
|
+
rows.push(...read.rows);
|
|
452
|
+
}
|
|
453
|
+
const windowFrom = Math.max(args.from, completeFrom, since, retainedBefore ?? 0);
|
|
454
|
+
const routes = new Map();
|
|
455
|
+
const totalsFor = (route) => {
|
|
456
|
+
let current = routes.get(route);
|
|
457
|
+
if (!current) {
|
|
458
|
+
current = {
|
|
459
|
+
pageviews: 0,
|
|
460
|
+
entries: 0,
|
|
461
|
+
continued: 0,
|
|
462
|
+
dwellMs: 0,
|
|
463
|
+
dwellSamples: 0,
|
|
464
|
+
transitionsIn: 0,
|
|
465
|
+
transitionsOut: 0,
|
|
466
|
+
sketch: createVisitorSketch(),
|
|
467
|
+
daily: new Map(),
|
|
468
|
+
};
|
|
469
|
+
routes.set(route, current);
|
|
470
|
+
}
|
|
471
|
+
return current;
|
|
472
|
+
};
|
|
473
|
+
const edges = new Map();
|
|
474
|
+
for (const row of rows) {
|
|
475
|
+
if (row.bucketStart < completeFrom)
|
|
476
|
+
continue;
|
|
477
|
+
const current = totalsFor(row.route);
|
|
478
|
+
current.pageviews += row.pageviews;
|
|
479
|
+
current.entries += row.entries;
|
|
480
|
+
current.continued += row.continued;
|
|
481
|
+
current.dwellMs += row.dwellMs;
|
|
482
|
+
current.dwellSamples += row.dwellSamples;
|
|
483
|
+
current.sketch = unionVisitorSketches(current.sketch, row.visitorSketch);
|
|
484
|
+
current.daily.set(row.bucketStart, (current.daily.get(row.bucketStart) ?? 0) + row.pageviews);
|
|
485
|
+
for (const target of row.outgoing) {
|
|
486
|
+
const key = `${row.route}\u0000${target.route}`;
|
|
487
|
+
const edge = edges.get(key) ?? {
|
|
488
|
+
from: row.route,
|
|
489
|
+
to: target.route,
|
|
490
|
+
count: 0,
|
|
491
|
+
};
|
|
492
|
+
edge.count += target.count;
|
|
493
|
+
edges.set(key, edge);
|
|
494
|
+
current.transitionsOut += target.count;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// A target without a row of its own in this window — folded past the
|
|
498
|
+
// daily cap, or already retained — counts under "(other)".
|
|
499
|
+
const transitions = new Map();
|
|
500
|
+
for (const edge of edges.values()) {
|
|
501
|
+
const to = routes.has(edge.to) ? edge.to : SITE_MAP_OTHER_ROUTE;
|
|
502
|
+
const key = `${edge.from}\u0000${to}`;
|
|
503
|
+
const merged = transitions.get(key) ?? { from: edge.from, to, count: 0 };
|
|
504
|
+
merged.count += edge.count;
|
|
505
|
+
transitions.set(key, merged);
|
|
506
|
+
totalsFor(to).transitionsIn += edge.count;
|
|
507
|
+
}
|
|
508
|
+
const routeRows = [...routes.entries()]
|
|
509
|
+
.map(([route, totals]) => ({
|
|
510
|
+
route,
|
|
511
|
+
pageviews: totals.pageviews,
|
|
512
|
+
visitors: estimateVisitors(totals.sketch),
|
|
513
|
+
entries: totals.entries,
|
|
514
|
+
exits: Math.max(0, totals.entries + totals.transitionsIn - totals.transitionsOut),
|
|
515
|
+
bounces: Math.max(0, totals.entries - totals.continued),
|
|
516
|
+
transitionsIn: totals.transitionsIn,
|
|
517
|
+
transitionsOut: totals.transitionsOut,
|
|
518
|
+
dwellMs: totals.dwellMs,
|
|
519
|
+
dwellSamples: totals.dwellSamples,
|
|
520
|
+
daily: [...totals.daily.entries()]
|
|
521
|
+
.sort(([left], [right]) => left - right)
|
|
522
|
+
.map(([bucketStart, pageviews]) => ({ bucketStart, pageviews })),
|
|
523
|
+
}))
|
|
524
|
+
.sort((left, right) => right.pageviews - left.pageviews ||
|
|
525
|
+
left.route.localeCompare(right.route));
|
|
526
|
+
const transitionRows = [...transitions.values()].sort((left, right) => right.count - left.count ||
|
|
527
|
+
left.from.localeCompare(right.from) ||
|
|
528
|
+
left.to.localeCompare(right.to));
|
|
529
|
+
return {
|
|
530
|
+
from: args.from,
|
|
531
|
+
to: args.to,
|
|
532
|
+
window: completeFrom > args.to ? null : { from: windowFrom, to: args.to },
|
|
533
|
+
routes: routeRows,
|
|
534
|
+
transitions: transitionRows,
|
|
535
|
+
truncated: {
|
|
536
|
+
routes: routes.has(SITE_MAP_OTHER_ROUTE),
|
|
537
|
+
transitions: transitionRows.some((transition) => transition.from === SITE_MAP_OTHER_ROUTE ||
|
|
538
|
+
transition.to === SITE_MAP_OTHER_ROUTE),
|
|
539
|
+
},
|
|
540
|
+
metadata: metadata(windowFrom > args.from ? "partial" : "complete"),
|
|
541
|
+
};
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
/**
|
|
545
|
+
* Reads a site's route rollups newest day first until the row budget or the
|
|
546
|
+
* transaction's read headroom runs out, then drops the day it could only
|
|
547
|
+
* read in part so every day it returns is complete.
|
|
548
|
+
*/
|
|
549
|
+
async function readRouteRollups(ctx, siteId, from, to, budget) {
|
|
550
|
+
const rows = [];
|
|
551
|
+
let cutAt = null;
|
|
552
|
+
const query = ctx.db
|
|
553
|
+
.query("routeDailyRollups")
|
|
554
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range
|
|
555
|
+
.eq("siteId", siteId)
|
|
556
|
+
.gte("bucketStart", from)
|
|
557
|
+
.lte("bucketStart", to))
|
|
558
|
+
.order("desc");
|
|
559
|
+
for await (const row of query) {
|
|
560
|
+
if (rows.length >= budget ||
|
|
561
|
+
(rows.length % 256 === 255 && !(await hasFeatureReadHeadroom(ctx)))) {
|
|
562
|
+
cutAt = row.bucketStart;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
rows.push(row);
|
|
566
|
+
}
|
|
567
|
+
if (cutAt === null)
|
|
568
|
+
return { rows, completeFrom: from };
|
|
569
|
+
while (rows.length > 0 && rows[rows.length - 1].bucketStart === cutAt) {
|
|
570
|
+
rows.pop();
|
|
571
|
+
}
|
|
572
|
+
return { rows, completeFrom: cutAt + DAY_MS };
|
|
573
|
+
}
|
|
227
574
|
export const listSessions = query({
|
|
228
575
|
args: {
|
|
229
576
|
siteId: v.id("sites"),
|
|
@@ -234,16 +581,50 @@ export const listSessions = query({
|
|
|
234
581
|
returns: paginationResultValidator(sessionDocumentValidator),
|
|
235
582
|
handler: async (ctx, args) => {
|
|
236
583
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
584
|
+
await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
|
|
585
|
+
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
586
|
+
kind: "sessions",
|
|
587
|
+
siteId: String(args.siteId),
|
|
588
|
+
from: args.from,
|
|
589
|
+
to: args.to,
|
|
590
|
+
});
|
|
591
|
+
const rows = [];
|
|
592
|
+
const take = pagination.numItems + 1;
|
|
593
|
+
if (pagination.cursor) {
|
|
594
|
+
rows.push(...(await ctx.db
|
|
595
|
+
.query("sessions")
|
|
596
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) => range
|
|
597
|
+
.eq("siteId", args.siteId)
|
|
598
|
+
.eq("lastSeenAt", pagination.cursor.timestamp)
|
|
599
|
+
.lt("_creationTime", pagination.cursor.creationTime))
|
|
600
|
+
.order("desc")
|
|
601
|
+
.take(take)));
|
|
602
|
+
if (rows.length < take) {
|
|
603
|
+
rows.push(...(await ctx.db
|
|
604
|
+
.query("sessions")
|
|
605
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) => range
|
|
606
|
+
.eq("siteId", args.siteId)
|
|
607
|
+
.gte("lastSeenAt", args.from)
|
|
608
|
+
.lt("lastSeenAt", pagination.cursor.timestamp))
|
|
609
|
+
.order("desc")
|
|
610
|
+
.take(take - rows.length)));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
rows.push(...(await ctx.db
|
|
615
|
+
.query("sessions")
|
|
616
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) => range
|
|
617
|
+
.eq("siteId", args.siteId)
|
|
618
|
+
.gte("lastSeenAt", args.from)
|
|
619
|
+
.lte("lastSeenAt", args.to))
|
|
620
|
+
.order("desc")
|
|
621
|
+
.take(take)));
|
|
622
|
+
}
|
|
623
|
+
const publicRows = rows.map(({ geoLookupAttemptedAt: _attemptedAt, lastPageviewAt: _lastPageviewAt, ...row }) => row);
|
|
624
|
+
return keysetPaginationResult(publicRows, pagination, (row) => ({
|
|
625
|
+
timestamp: row.lastSeenAt,
|
|
626
|
+
creationTime: row._creationTime,
|
|
627
|
+
}));
|
|
247
628
|
},
|
|
248
629
|
});
|
|
249
630
|
export const sessionJourney = query({
|
|
@@ -257,17 +638,24 @@ export const sessionJourney = query({
|
|
|
257
638
|
returns: paginationResultValidator(eventDocumentValidator),
|
|
258
639
|
handler: async (ctx, args) => {
|
|
259
640
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
641
|
+
await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
|
|
642
|
+
const pagination = parseJourneyPagination(args.paginationOpts, {
|
|
643
|
+
kind: "events",
|
|
644
|
+
siteId: String(args.siteId),
|
|
645
|
+
sessionId: args.sessionId,
|
|
646
|
+
from: args.from,
|
|
647
|
+
to: args.to,
|
|
648
|
+
});
|
|
649
|
+
const rows = await loadSessionJourneyEvents(ctx, {
|
|
650
|
+
siteId: args.siteId,
|
|
651
|
+
sessionId: args.sessionId,
|
|
652
|
+
from: args.from,
|
|
653
|
+
to: args.to,
|
|
654
|
+
cursor: pagination.cursor,
|
|
655
|
+
resultLimit: pagination.numItems + 1,
|
|
656
|
+
});
|
|
657
|
+
const hydratedRows = await hydrateEventContexts(ctx, rows);
|
|
658
|
+
return journeyPaginationResult(hydratedRows, pagination);
|
|
271
659
|
},
|
|
272
660
|
});
|
|
273
661
|
export const listConversions = query({
|
|
@@ -280,16 +668,49 @@ export const listConversions = query({
|
|
|
280
668
|
returns: paginationResultValidator(conversionDocumentValidator),
|
|
281
669
|
handler: async (ctx, args) => {
|
|
282
670
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
671
|
+
await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
|
|
672
|
+
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
673
|
+
kind: "conversions",
|
|
674
|
+
siteId: String(args.siteId),
|
|
675
|
+
from: args.from,
|
|
676
|
+
to: args.to,
|
|
677
|
+
});
|
|
678
|
+
const rows = [];
|
|
679
|
+
const take = pagination.numItems + 1;
|
|
680
|
+
if (pagination.cursor) {
|
|
681
|
+
rows.push(...(await ctx.db
|
|
682
|
+
.query("conversions")
|
|
683
|
+
.withIndex("by_siteId_and_timestamp", (range) => range
|
|
684
|
+
.eq("siteId", args.siteId)
|
|
685
|
+
.eq("timestamp", pagination.cursor.timestamp)
|
|
686
|
+
.lt("_creationTime", pagination.cursor.creationTime))
|
|
687
|
+
.order("desc")
|
|
688
|
+
.take(take)));
|
|
689
|
+
if (rows.length < take) {
|
|
690
|
+
rows.push(...(await ctx.db
|
|
691
|
+
.query("conversions")
|
|
692
|
+
.withIndex("by_siteId_and_timestamp", (range) => range
|
|
693
|
+
.eq("siteId", args.siteId)
|
|
694
|
+
.gte("timestamp", args.from)
|
|
695
|
+
.lt("timestamp", pagination.cursor.timestamp))
|
|
696
|
+
.order("desc")
|
|
697
|
+
.take(take - rows.length)));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
rows.push(...(await ctx.db
|
|
702
|
+
.query("conversions")
|
|
703
|
+
.withIndex("by_siteId_and_timestamp", (range) => range
|
|
704
|
+
.eq("siteId", args.siteId)
|
|
705
|
+
.gte("timestamp", args.from)
|
|
706
|
+
.lte("timestamp", args.to))
|
|
707
|
+
.order("desc")
|
|
708
|
+
.take(take)));
|
|
709
|
+
}
|
|
710
|
+
return keysetPaginationResult(rows, pagination, (row) => ({
|
|
711
|
+
timestamp: row.timestamp,
|
|
712
|
+
creationTime: row._creationTime,
|
|
713
|
+
}));
|
|
293
714
|
},
|
|
294
715
|
});
|
|
295
716
|
export const visitorJourney = query({
|
|
@@ -304,25 +725,30 @@ export const visitorJourney = query({
|
|
|
304
725
|
handler: async (ctx, args) => {
|
|
305
726
|
const siteIds = validateSiteIds(args.siteIds);
|
|
306
727
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
728
|
+
await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
|
|
307
729
|
const limit = args.limit ?? 200;
|
|
308
|
-
if (!Number.isSafeInteger(limit) ||
|
|
730
|
+
if (!Number.isSafeInteger(limit) ||
|
|
731
|
+
limit < 1 ||
|
|
732
|
+
limit > MAX_JOURNEY_EVENTS) {
|
|
309
733
|
fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
|
|
310
734
|
}
|
|
311
735
|
const rows = [];
|
|
312
736
|
for (const siteId of siteIds) {
|
|
313
|
-
|
|
314
|
-
.
|
|
315
|
-
.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
737
|
+
for (const visitorId of await resolveVisitorIdentities(ctx, siteId, args.visitorId)) {
|
|
738
|
+
const cutoff = rows.length >= limit ? rows[limit - 1].timestamp : args.to;
|
|
739
|
+
rows.push(...(await loadVisitorJourneyEvents(ctx, {
|
|
740
|
+
siteId,
|
|
741
|
+
visitorId,
|
|
742
|
+
from: args.from,
|
|
743
|
+
to: cutoff,
|
|
744
|
+
resultLimit: limit,
|
|
745
|
+
})));
|
|
746
|
+
rows.sort(compareJourneyEvents);
|
|
747
|
+
if (rows.length > limit)
|
|
748
|
+
rows.length = limit;
|
|
749
|
+
}
|
|
322
750
|
}
|
|
323
|
-
return rows
|
|
324
|
-
.sort((left, right) => left.timestamp - right.timestamp || left.sequence - right.sequence)
|
|
325
|
-
.slice(0, limit);
|
|
751
|
+
return hydrateEventContexts(ctx, rows);
|
|
326
752
|
},
|
|
327
753
|
});
|
|
328
754
|
const goalReportItemValidator = goalFieldsValidator.extend({
|
|
@@ -336,69 +762,126 @@ export const goalsReport = query({
|
|
|
336
762
|
returns: v.object({
|
|
337
763
|
goals: v.array(goalReportItemValidator),
|
|
338
764
|
truncated: v.boolean(),
|
|
765
|
+
metadata: reportMetadataValidator,
|
|
339
766
|
}),
|
|
340
767
|
handler: async (ctx, args) => {
|
|
341
768
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
342
|
-
const
|
|
769
|
+
const goalRows = await ctx.db
|
|
343
770
|
.query("goals")
|
|
344
771
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
345
|
-
.take(MAX_GOALS_PER_SITE);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
.withIndex("by_siteId_and_timestamp", (range) => range
|
|
349
|
-
.eq("siteId", args.siteId)
|
|
350
|
-
.gte("timestamp", args.from)
|
|
351
|
-
.lte("timestamp", args.to))
|
|
352
|
-
.take(5_001);
|
|
772
|
+
.take(MAX_GOALS_PER_SITE + 1);
|
|
773
|
+
failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
|
|
774
|
+
const goals = goalRows;
|
|
353
775
|
const totals = new Map();
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
776
|
+
const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "goalRollups");
|
|
777
|
+
const rollups = useRollups
|
|
778
|
+
? await loadGoalDailyRollups(ctx, args.siteId, goals.map((goal) => goal._id), args.from, args.to)
|
|
779
|
+
: null;
|
|
780
|
+
if (rollups) {
|
|
781
|
+
for (const row of rollups) {
|
|
782
|
+
const key = String(row.goalId);
|
|
783
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
784
|
+
total.completions += row.completions;
|
|
785
|
+
total.valueCents += row.valueCents;
|
|
786
|
+
totals.set(key, total);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
await assertSourceAvailable(ctx, [args.siteId], ["goalCompletions"], args.from);
|
|
791
|
+
const completions = await ctx.db
|
|
792
|
+
.query("goalCompletions")
|
|
793
|
+
.withIndex("by_siteId_and_timestamp", (range) => range
|
|
794
|
+
.eq("siteId", args.siteId)
|
|
795
|
+
.gte("timestamp", args.from)
|
|
796
|
+
.lte("timestamp", args.to))
|
|
797
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
798
|
+
failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
|
|
799
|
+
for (const completion of completions) {
|
|
800
|
+
const key = String(completion.goalId);
|
|
801
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
802
|
+
total.completions += 1;
|
|
803
|
+
total.valueCents += completion.valueCents;
|
|
804
|
+
totals.set(key, total);
|
|
805
|
+
}
|
|
360
806
|
}
|
|
361
807
|
return {
|
|
362
808
|
goals: goals.map((goal) => ({
|
|
363
809
|
...goal,
|
|
364
810
|
...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
|
|
365
811
|
})),
|
|
366
|
-
truncated:
|
|
812
|
+
truncated: false,
|
|
813
|
+
metadata: {
|
|
814
|
+
basis: rollups ? "dailyRollup" : "boundedLegacy",
|
|
815
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
816
|
+
datasets: [rollups ? "goalRollups" : "goalCompletions"],
|
|
817
|
+
complete: true,
|
|
818
|
+
generation: rollups ? 1 : null,
|
|
819
|
+
}),
|
|
820
|
+
range: rangeSemantics(args.from, args.to),
|
|
821
|
+
},
|
|
367
822
|
};
|
|
368
823
|
},
|
|
369
824
|
});
|
|
370
825
|
const funnelReportItemValidator = funnelFieldsValidator.extend({
|
|
371
826
|
_id: v.id("funnels"),
|
|
372
827
|
_creationTime: v.number(),
|
|
373
|
-
stepsReport: v.array(v.object({
|
|
828
|
+
stepsReport: v.array(v.object({
|
|
829
|
+
step: v.number(),
|
|
830
|
+
name: v.string(),
|
|
831
|
+
entrants: v.number(),
|
|
832
|
+
completions: v.number(),
|
|
833
|
+
})),
|
|
374
834
|
truncated: v.boolean(),
|
|
835
|
+
metadata: reportMetadataValidator,
|
|
375
836
|
});
|
|
376
837
|
export const funnelsReport = query({
|
|
377
838
|
args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
|
|
378
839
|
returns: v.array(funnelReportItemValidator),
|
|
379
840
|
handler: async (ctx, args) => {
|
|
380
841
|
validateRange(args.from, args.to, 90);
|
|
381
|
-
|
|
842
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
|
|
843
|
+
const funnelRows = await ctx.db
|
|
382
844
|
.query("funnels")
|
|
383
845
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
384
|
-
.take(MAX_FUNNELS_PER_SITE);
|
|
846
|
+
.take(MAX_FUNNELS_PER_SITE + 1);
|
|
847
|
+
failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
|
|
848
|
+
const funnels = funnelRows;
|
|
849
|
+
const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "funnelRollups");
|
|
850
|
+
const rollups = useRollups
|
|
851
|
+
? await loadFunnelDailyRollups(ctx, args.siteId, funnels.map((funnel) => funnel._id), args.from, args.to)
|
|
852
|
+
: null;
|
|
853
|
+
if (!rollups) {
|
|
854
|
+
await assertSourceAvailable(ctx, [args.siteId], ["funnelSteps"], args.from);
|
|
855
|
+
}
|
|
856
|
+
const reportCoverage = await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
857
|
+
datasets: [rollups ? "funnelRollups" : "funnelSteps"],
|
|
858
|
+
complete: true,
|
|
859
|
+
generation: rollups ? 1 : null,
|
|
860
|
+
});
|
|
385
861
|
const reports = [];
|
|
386
862
|
for (const funnel of funnels) {
|
|
387
|
-
const buckets = await ctx.db
|
|
388
|
-
.query("funnelStepBuckets")
|
|
389
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
|
|
390
|
-
.eq("siteId", args.siteId)
|
|
391
|
-
.eq("funnelId", funnel._id)
|
|
392
|
-
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
393
|
-
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
394
|
-
.take(601);
|
|
395
863
|
const stepsReport = funnel.steps.map((step, index) => ({
|
|
396
864
|
step: index,
|
|
397
865
|
name: step.name,
|
|
398
866
|
entrants: 0,
|
|
399
867
|
completions: 0,
|
|
400
868
|
}));
|
|
401
|
-
|
|
869
|
+
const buckets = rollups
|
|
870
|
+
? rollups
|
|
871
|
+
.filter((row) => row.funnelId === funnel._id)
|
|
872
|
+
.flatMap((row) => row.steps)
|
|
873
|
+
: await ctx.db
|
|
874
|
+
.query("funnelStepBuckets")
|
|
875
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
|
|
876
|
+
.eq("siteId", args.siteId)
|
|
877
|
+
.eq("funnelId", funnel._id)
|
|
878
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
879
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
880
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
881
|
+
if (!rollups) {
|
|
882
|
+
failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
|
|
883
|
+
}
|
|
884
|
+
for (const bucket of buckets) {
|
|
402
885
|
const step = stepsReport[bucket.step];
|
|
403
886
|
if (step) {
|
|
404
887
|
step.entrants += bucket.entrants;
|
|
@@ -408,7 +891,14 @@ export const funnelsReport = query({
|
|
|
408
891
|
reports.push({
|
|
409
892
|
...funnel,
|
|
410
893
|
stepsReport,
|
|
411
|
-
truncated:
|
|
894
|
+
truncated: false,
|
|
895
|
+
metadata: {
|
|
896
|
+
basis: rollups
|
|
897
|
+
? "dailyRollup"
|
|
898
|
+
: "boundedLegacy",
|
|
899
|
+
coverage: reportCoverage,
|
|
900
|
+
range: rangeSemantics(args.from, args.to),
|
|
901
|
+
},
|
|
412
902
|
});
|
|
413
903
|
}
|
|
414
904
|
return reports;
|
|
@@ -428,54 +918,94 @@ export const affiliatesReport = query({
|
|
|
428
918
|
returns: v.object({
|
|
429
919
|
affiliates: v.array(affiliateReportItemValidator),
|
|
430
920
|
truncated: v.boolean(),
|
|
921
|
+
metadata: reportMetadataValidator.extend({
|
|
922
|
+
uniqueVisitors: v.object({
|
|
923
|
+
basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
|
|
924
|
+
exact: v.boolean(),
|
|
925
|
+
}),
|
|
926
|
+
}),
|
|
431
927
|
}),
|
|
432
928
|
handler: async (ctx, args) => {
|
|
433
929
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
930
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
|
|
434
931
|
const affiliateRows = await ctx.db
|
|
435
932
|
.query("affiliates")
|
|
436
933
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
437
934
|
.order("desc")
|
|
438
935
|
.take(MAX_AFFILIATES_PER_SITE + 1);
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
446
|
-
.take(5_001);
|
|
447
|
-
const sessions = await ctx.db
|
|
448
|
-
.query("sessions")
|
|
449
|
-
.withIndex("by_siteId_and_startedAt", (range) => range
|
|
450
|
-
.eq("siteId", args.siteId)
|
|
451
|
-
.gte("startedAt", args.from)
|
|
452
|
-
.lte("startedAt", args.to))
|
|
453
|
-
.take(5_001);
|
|
936
|
+
failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
|
|
937
|
+
const affiliates = affiliateRows;
|
|
938
|
+
const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "affiliateRollups");
|
|
939
|
+
const rollups = useRollups
|
|
940
|
+
? await loadAffiliateDailyRollups(ctx, args.siteId, affiliates.map((affiliate) => affiliate._id), args.from, args.to)
|
|
941
|
+
: null;
|
|
454
942
|
const visits = new Map();
|
|
455
|
-
for (const session of sessions.slice(0, 5_000)) {
|
|
456
|
-
if (!session.affiliateId)
|
|
457
|
-
continue;
|
|
458
|
-
const key = String(session.affiliateId);
|
|
459
|
-
const total = visits.get(key) ?? {
|
|
460
|
-
visits: 0,
|
|
461
|
-
visitors: new Set(),
|
|
462
|
-
};
|
|
463
|
-
total.visits += 1;
|
|
464
|
-
total.visitors.add(session.visitorId);
|
|
465
|
-
visits.set(key, total);
|
|
466
|
-
}
|
|
467
943
|
const totals = new Map();
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
944
|
+
if (rollups) {
|
|
945
|
+
for (const row of rollups) {
|
|
946
|
+
const key = String(row.affiliateId);
|
|
947
|
+
const traffic = visits.get(key) ?? {
|
|
948
|
+
visits: 0,
|
|
949
|
+
visitors: new Set(),
|
|
950
|
+
sketch: createVisitorSketch(),
|
|
951
|
+
};
|
|
952
|
+
traffic.visits += row.visits;
|
|
953
|
+
traffic.sketch = unionVisitorSketches(traffic.sketch, row.visitorSketch);
|
|
954
|
+
visits.set(key, traffic);
|
|
955
|
+
const total = totals.get(key) ?? {
|
|
956
|
+
conversions: 0,
|
|
957
|
+
revenueCents: 0,
|
|
958
|
+
commissionCents: 0,
|
|
959
|
+
};
|
|
960
|
+
total.conversions += row.conversions;
|
|
961
|
+
total.revenueCents += row.revenueCents;
|
|
962
|
+
total.commissionCents += row.commissionCents;
|
|
963
|
+
totals.set(key, total);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
await assertSourceAvailable(ctx, [args.siteId], ["affiliateStats", "sessions"], args.from);
|
|
968
|
+
const stats = await ctx.db
|
|
969
|
+
.query("affiliateDailyStats")
|
|
970
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range
|
|
971
|
+
.eq("siteId", args.siteId)
|
|
972
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
973
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
|
|
974
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
975
|
+
const sessions = await ctx.db
|
|
976
|
+
.query("sessions")
|
|
977
|
+
.withIndex("by_siteId_and_startedAt", (range) => range
|
|
978
|
+
.eq("siteId", args.siteId)
|
|
979
|
+
.gte("startedAt", args.from)
|
|
980
|
+
.lte("startedAt", args.to))
|
|
981
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
982
|
+
failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
|
|
983
|
+
failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
|
|
984
|
+
for (const session of sessions) {
|
|
985
|
+
if (!session.affiliateId)
|
|
986
|
+
continue;
|
|
987
|
+
const key = String(session.affiliateId);
|
|
988
|
+
const total = visits.get(key) ?? {
|
|
989
|
+
visits: 0,
|
|
990
|
+
visitors: new Set(),
|
|
991
|
+
sketch: createVisitorSketch(),
|
|
992
|
+
};
|
|
993
|
+
total.visits += 1;
|
|
994
|
+
total.visitors.add(session.visitorId);
|
|
995
|
+
visits.set(key, total);
|
|
996
|
+
}
|
|
997
|
+
for (const row of stats) {
|
|
998
|
+
const key = String(row.affiliateId);
|
|
999
|
+
const total = totals.get(key) ?? {
|
|
1000
|
+
conversions: 0,
|
|
1001
|
+
revenueCents: 0,
|
|
1002
|
+
commissionCents: 0,
|
|
1003
|
+
};
|
|
1004
|
+
total.conversions += row.conversions;
|
|
1005
|
+
total.revenueCents += row.revenueCents;
|
|
1006
|
+
total.commissionCents += row.commissionCents;
|
|
1007
|
+
totals.set(key, total);
|
|
1008
|
+
}
|
|
479
1009
|
}
|
|
480
1010
|
return {
|
|
481
1011
|
affiliates: affiliates.map((affiliate) => {
|
|
@@ -489,15 +1019,548 @@ export const affiliatesReport = query({
|
|
|
489
1019
|
...affiliate,
|
|
490
1020
|
...performance,
|
|
491
1021
|
visits: traffic?.visits ?? 0,
|
|
492
|
-
uniqueVisitors:
|
|
1022
|
+
uniqueVisitors: rollups
|
|
1023
|
+
? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
|
|
1024
|
+
: (traffic?.visitors.size ?? 0),
|
|
493
1025
|
};
|
|
494
1026
|
}),
|
|
495
|
-
truncated:
|
|
496
|
-
|
|
497
|
-
|
|
1027
|
+
truncated: false,
|
|
1028
|
+
metadata: {
|
|
1029
|
+
basis: rollups ? "dailyRollup" : "boundedLegacy",
|
|
1030
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
1031
|
+
datasets: rollups
|
|
1032
|
+
? ["affiliateRollups"]
|
|
1033
|
+
: ["affiliateStats", "sessions"],
|
|
1034
|
+
complete: true,
|
|
1035
|
+
generation: rollups ? 1 : null,
|
|
1036
|
+
}),
|
|
1037
|
+
range: rangeSemantics(args.from, args.to),
|
|
1038
|
+
uniqueVisitors: {
|
|
1039
|
+
basis: rollups
|
|
1040
|
+
? "estimatedUnique"
|
|
1041
|
+
: "exactLegacy",
|
|
1042
|
+
exact: !rollups,
|
|
1043
|
+
},
|
|
1044
|
+
},
|
|
1045
|
+
};
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
const vitalStatsValidator = v.object({
|
|
1049
|
+
count: v.number(),
|
|
1050
|
+
p75: v.number(),
|
|
1051
|
+
average: v.number(),
|
|
1052
|
+
good: v.number(),
|
|
1053
|
+
needsImprovement: v.number(),
|
|
1054
|
+
poor: v.number(),
|
|
1055
|
+
});
|
|
1056
|
+
export const vitalsReport = query({
|
|
1057
|
+
args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
|
|
1058
|
+
returns: v.object({
|
|
1059
|
+
metrics: v.array(vitalStatsValidator.extend({
|
|
1060
|
+
metric: vitalMetricValidator,
|
|
1061
|
+
devices: v.array(vitalStatsValidator.extend({ device: v.string() })),
|
|
1062
|
+
pages: v.array(vitalStatsValidator.extend({ page: v.string() })),
|
|
1063
|
+
timeSeries: v.array(v.object({
|
|
1064
|
+
timestamp: v.number(),
|
|
1065
|
+
count: v.number(),
|
|
1066
|
+
p75: v.number(),
|
|
1067
|
+
})),
|
|
1068
|
+
})),
|
|
1069
|
+
truncated: v.boolean(),
|
|
1070
|
+
metadata: reportMetadataValidator,
|
|
1071
|
+
}),
|
|
1072
|
+
handler: async (ctx, args) => {
|
|
1073
|
+
validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
|
|
1074
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "vitals report");
|
|
1075
|
+
await assertSourceAvailable(ctx, [args.siteId], ["vitalRollups"], args.from);
|
|
1076
|
+
const rows = await ctx.db
|
|
1077
|
+
.query("vitalRollups")
|
|
1078
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range
|
|
1079
|
+
.eq("siteId", args.siteId)
|
|
1080
|
+
.gte("bucketStart", args.from)
|
|
1081
|
+
.lte("bucketStart", args.to))
|
|
1082
|
+
.take(MAX_VITALS_REPORT_ROWS + 1);
|
|
1083
|
+
failIfIncomplete(rows, MAX_VITALS_REPORT_ROWS, "vital rollups");
|
|
1084
|
+
const fold = (groups, metric, key, entry) => {
|
|
1085
|
+
const group = groups.get(metric) ?? new Map();
|
|
1086
|
+
const current = group.get(key) ?? { count: 0, sum: 0, histogram: [] };
|
|
1087
|
+
current.count += entry.count;
|
|
1088
|
+
current.sum += entry.sum;
|
|
1089
|
+
current.histogram = mergeVitalHistograms(current.histogram, entry.histogram);
|
|
1090
|
+
group.set(key, current);
|
|
1091
|
+
groups.set(metric, group);
|
|
1092
|
+
};
|
|
1093
|
+
const byDevice = new Map();
|
|
1094
|
+
const byPage = new Map();
|
|
1095
|
+
const byDay = new Map();
|
|
1096
|
+
for (const row of rows) {
|
|
1097
|
+
for (const entry of row.metrics) {
|
|
1098
|
+
if (row.page === VITAL_ALL) {
|
|
1099
|
+
// A device row carries the complete signal once, so overall totals
|
|
1100
|
+
// and the time series derive from this family alone.
|
|
1101
|
+
fold(byDevice, entry.metric, row.device, entry);
|
|
1102
|
+
fold(byDay, entry.metric, row.bucketStart, entry);
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
fold(byPage, entry.metric, row.page, entry);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const stats = (metric, accumulated) => ({
|
|
1110
|
+
count: accumulated.count,
|
|
1111
|
+
p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
|
|
1112
|
+
average: accumulated.count > 0
|
|
1113
|
+
? Math.round(accumulated.sum / accumulated.count)
|
|
1114
|
+
: 0,
|
|
1115
|
+
...vitalRatingCounts(accumulated.histogram, metric),
|
|
1116
|
+
});
|
|
1117
|
+
const metrics = VITAL_METRICS.map((metric) => {
|
|
1118
|
+
const overall = { count: 0, sum: 0, histogram: [] };
|
|
1119
|
+
for (const accumulated of byDevice.get(metric)?.values() ?? []) {
|
|
1120
|
+
overall.count += accumulated.count;
|
|
1121
|
+
overall.sum += accumulated.sum;
|
|
1122
|
+
overall.histogram = mergeVitalHistograms(overall.histogram, accumulated.histogram);
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
metric,
|
|
1126
|
+
...stats(metric, overall),
|
|
1127
|
+
devices: [...(byDevice.get(metric) ?? [])]
|
|
1128
|
+
.map(([device, accumulated]) => ({
|
|
1129
|
+
device,
|
|
1130
|
+
...stats(metric, accumulated),
|
|
1131
|
+
}))
|
|
1132
|
+
.sort((left, right) => right.count - left.count),
|
|
1133
|
+
pages: [...(byPage.get(metric) ?? [])]
|
|
1134
|
+
.map(([page, accumulated]) => ({
|
|
1135
|
+
page,
|
|
1136
|
+
...stats(metric, accumulated),
|
|
1137
|
+
}))
|
|
1138
|
+
.sort((left, right) => right.count - left.count || left.page.localeCompare(right.page))
|
|
1139
|
+
.slice(0, VITALS_REPORT_PAGE_LIMIT),
|
|
1140
|
+
timeSeries: [...(byDay.get(metric) ?? [])]
|
|
1141
|
+
.sort(([left], [right]) => left - right)
|
|
1142
|
+
.map(([timestamp, accumulated]) => ({
|
|
1143
|
+
timestamp,
|
|
1144
|
+
count: accumulated.count,
|
|
1145
|
+
p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
|
|
1146
|
+
})),
|
|
1147
|
+
};
|
|
1148
|
+
});
|
|
1149
|
+
return {
|
|
1150
|
+
metrics,
|
|
1151
|
+
truncated: false,
|
|
1152
|
+
metadata: {
|
|
1153
|
+
basis: "dailyRollup",
|
|
1154
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, { datasets: ["vitalRollups"], complete: true }),
|
|
1155
|
+
range: rangeSemantics(args.from, args.to),
|
|
1156
|
+
},
|
|
1157
|
+
};
|
|
1158
|
+
},
|
|
1159
|
+
});
|
|
1160
|
+
const coverageDatasetValidator = v.union(v.literal("overviewHour"), v.literal("overviewDay"), v.literal("events"), v.literal("sessions"), v.literal("conversions"), v.literal("goals"), v.literal("funnels"), v.literal("affiliates"), v.literal("vitals"), v.literal("siteMap"));
|
|
1161
|
+
export const dataCoverage = query({
|
|
1162
|
+
args: {
|
|
1163
|
+
siteId: v.id("sites"),
|
|
1164
|
+
from: v.number(),
|
|
1165
|
+
to: v.number(),
|
|
1166
|
+
},
|
|
1167
|
+
returns: v.object({
|
|
1168
|
+
siteId: v.id("sites"),
|
|
1169
|
+
from: v.number(),
|
|
1170
|
+
to: v.number(),
|
|
1171
|
+
datasets: v.array(v.object({
|
|
1172
|
+
dataset: coverageDatasetValidator,
|
|
1173
|
+
coverage: resultCoverageValidator,
|
|
1174
|
+
})),
|
|
1175
|
+
}),
|
|
1176
|
+
handler: async (ctx, args) => {
|
|
1177
|
+
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
1178
|
+
const datasets = [
|
|
1179
|
+
"overviewHour",
|
|
1180
|
+
"overviewDay",
|
|
1181
|
+
"events",
|
|
1182
|
+
"sessions",
|
|
1183
|
+
"conversions",
|
|
1184
|
+
"goals",
|
|
1185
|
+
"funnels",
|
|
1186
|
+
"affiliates",
|
|
1187
|
+
"vitals",
|
|
1188
|
+
"siteMap",
|
|
1189
|
+
];
|
|
1190
|
+
return {
|
|
1191
|
+
siteId: args.siteId,
|
|
1192
|
+
from: args.from,
|
|
1193
|
+
to: args.to,
|
|
1194
|
+
datasets: await Promise.all(datasets.map(async (dataset) => ({
|
|
1195
|
+
dataset,
|
|
1196
|
+
coverage: await datasetCoverageForRange(ctx, args.siteId, dataset, args.from, args.to),
|
|
1197
|
+
}))),
|
|
498
1198
|
};
|
|
499
1199
|
},
|
|
500
1200
|
});
|
|
1201
|
+
async function dailyRollupsAvailable(ctx, siteId, from, to, dataset) {
|
|
1202
|
+
if (!isAlignedRange(from, to, DAY_MS))
|
|
1203
|
+
return false;
|
|
1204
|
+
const coverage = await ctx.db
|
|
1205
|
+
.query("analyticsCoverage")
|
|
1206
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1207
|
+
.unique();
|
|
1208
|
+
if (!coverage || coverage.rollupCompleteFrom > from) {
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
const retainedBefore = coverage.retained.find((item) => item.dataset === dataset)?.before;
|
|
1212
|
+
return retainedBefore === undefined || from >= retainedBefore;
|
|
1213
|
+
}
|
|
1214
|
+
async function loadGoalDailyRollups(ctx, siteId, goalIds, from, to) {
|
|
1215
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1216
|
+
requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
|
|
1217
|
+
const rows = [];
|
|
1218
|
+
for (const goalId of goalIds) {
|
|
1219
|
+
const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1220
|
+
const goalRows = await ctx.db
|
|
1221
|
+
.query("goalDailyRollups")
|
|
1222
|
+
.withIndex("by_siteId_and_goalId_and_bucketStart", (range) => range
|
|
1223
|
+
.eq("siteId", siteId)
|
|
1224
|
+
.eq("goalId", goalId)
|
|
1225
|
+
.gte("bucketStart", from)
|
|
1226
|
+
.lte("bucketStart", to))
|
|
1227
|
+
.take(perGoalLimit + 1);
|
|
1228
|
+
failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
|
|
1229
|
+
rows.push(...goalRows);
|
|
1230
|
+
await requireFeatureReadHeadroom(ctx, "goal daily rollups");
|
|
1231
|
+
}
|
|
1232
|
+
return rows;
|
|
1233
|
+
}
|
|
1234
|
+
async function loadFunnelDailyRollups(ctx, siteId, funnelIds, from, to) {
|
|
1235
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1236
|
+
requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
|
|
1237
|
+
const rows = [];
|
|
1238
|
+
for (const funnelId of funnelIds) {
|
|
1239
|
+
const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1240
|
+
const funnelRows = await ctx.db
|
|
1241
|
+
.query("funnelDailyRollups")
|
|
1242
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
|
|
1243
|
+
.eq("siteId", siteId)
|
|
1244
|
+
.eq("funnelId", funnelId)
|
|
1245
|
+
.gte("bucketStart", from)
|
|
1246
|
+
.lte("bucketStart", to))
|
|
1247
|
+
.take(perFunnelLimit + 1);
|
|
1248
|
+
failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
|
|
1249
|
+
rows.push(...funnelRows);
|
|
1250
|
+
await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
|
|
1251
|
+
}
|
|
1252
|
+
return rows;
|
|
1253
|
+
}
|
|
1254
|
+
async function loadAffiliateDailyRollups(ctx, siteId, affiliateIds, from, to) {
|
|
1255
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1256
|
+
requireFeatureRollupBudget(affiliateIds.length, days, "affiliate daily rollups");
|
|
1257
|
+
const rows = [];
|
|
1258
|
+
for (const affiliateId of affiliateIds) {
|
|
1259
|
+
const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1260
|
+
const affiliateRows = await ctx.db
|
|
1261
|
+
.query("affiliateDailyRollups")
|
|
1262
|
+
.withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) => range
|
|
1263
|
+
.eq("siteId", siteId)
|
|
1264
|
+
.eq("affiliateId", affiliateId)
|
|
1265
|
+
.gte("bucketStart", from)
|
|
1266
|
+
.lte("bucketStart", to))
|
|
1267
|
+
.take(perAffiliateLimit + 1);
|
|
1268
|
+
failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
|
|
1269
|
+
rows.push(...affiliateRows);
|
|
1270
|
+
await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
|
|
1271
|
+
}
|
|
1272
|
+
return rows;
|
|
1273
|
+
}
|
|
1274
|
+
function requireFeatureRollupBudget(entities, days, dataset) {
|
|
1275
|
+
const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
|
|
1276
|
+
if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
|
|
1277
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
|
|
1278
|
+
dataset,
|
|
1279
|
+
maximumDays: MAX_FEATURE_ROLLUP_DAYS,
|
|
1280
|
+
maximumRows: MAX_FEATURE_ROLLUP_ROWS,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
async function hasFeatureReadHeadroom(ctx) {
|
|
1285
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
1286
|
+
return (metrics.bytesRead.remaining >= FEATURE_REPORT_BYTE_RESERVE &&
|
|
1287
|
+
metrics.documentsRead.remaining >= FEATURE_REPORT_DOCUMENT_RESERVE);
|
|
1288
|
+
}
|
|
1289
|
+
async function requireFeatureReadHeadroom(ctx, dataset) {
|
|
1290
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
1291
|
+
if (metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
|
|
1292
|
+
metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE) {
|
|
1293
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
|
|
1294
|
+
dataset,
|
|
1295
|
+
bytesRead: metrics.bytesRead.used,
|
|
1296
|
+
documentsRead: metrics.documentsRead.used,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
function failIfIncomplete(rows, maximum, dataset) {
|
|
1301
|
+
if (rows.length > maximum) {
|
|
1302
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
|
|
1303
|
+
maximumRows: maximum,
|
|
1304
|
+
dataset,
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
async function coverageForRange(ctx, siteIds, from, to, options) {
|
|
1309
|
+
const controls = await Promise.all(siteIds.map((siteId) => ctx.db
|
|
1310
|
+
.query("analyticsCoverage")
|
|
1311
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1312
|
+
.unique()));
|
|
1313
|
+
const sourceRanges = await Promise.all(siteIds.map((siteId) => sourceSetRange(ctx, siteId, options.datasets)));
|
|
1314
|
+
const retainedBefore = controls.reduce((latest, control) => {
|
|
1315
|
+
const before = control?.retained
|
|
1316
|
+
.filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
|
|
1317
|
+
.reduce((latest, item) => Math.max(latest ?? 0, item.before), undefined);
|
|
1318
|
+
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
1319
|
+
}, null);
|
|
1320
|
+
const availableFrom = options.generation === 1
|
|
1321
|
+
? controls.length > 0 && controls.every(Boolean)
|
|
1322
|
+
? Math.max(...controls.map((control) => control.rollupCompleteFrom))
|
|
1323
|
+
: null
|
|
1324
|
+
: sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1325
|
+
? Math.max(...sourceRanges.map((range) => range.from))
|
|
1326
|
+
: null;
|
|
1327
|
+
const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1328
|
+
? Math.min(...sourceRanges.map((range) => range.through))
|
|
1329
|
+
: null;
|
|
1330
|
+
let state;
|
|
1331
|
+
if (retainedBefore !== null && to < retainedBefore) {
|
|
1332
|
+
state = "unavailable";
|
|
1333
|
+
}
|
|
1334
|
+
else if (retainedBefore !== null && from < retainedBefore) {
|
|
1335
|
+
state = "partial";
|
|
1336
|
+
}
|
|
1337
|
+
else if (options.complete ||
|
|
1338
|
+
(availableFrom !== null &&
|
|
1339
|
+
availableThrough !== null &&
|
|
1340
|
+
from >= availableFrom &&
|
|
1341
|
+
to <= availableThrough)) {
|
|
1342
|
+
state = "complete";
|
|
1343
|
+
}
|
|
1344
|
+
else if (sourceRanges.some(Boolean)) {
|
|
1345
|
+
state = "retained";
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
state = "unavailable";
|
|
1349
|
+
}
|
|
1350
|
+
return {
|
|
1351
|
+
state,
|
|
1352
|
+
generation: options.generation ?? null,
|
|
1353
|
+
availableFrom,
|
|
1354
|
+
availableThrough,
|
|
1355
|
+
retainedBefore,
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
async function assertSourceAvailable(ctx, siteIds, sources, from) {
|
|
1359
|
+
const rows = await Promise.all(siteIds.map((siteId) => ctx.db
|
|
1360
|
+
.query("analyticsCoverage")
|
|
1361
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1362
|
+
.unique()));
|
|
1363
|
+
const cutoff = rows.reduce((latest, row) => {
|
|
1364
|
+
for (const retained of row?.retained ?? []) {
|
|
1365
|
+
if (sources.some((source) => source === retained.dataset)) {
|
|
1366
|
+
latest = Math.max(latest ?? 0, retained.before);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return latest;
|
|
1370
|
+
}, null);
|
|
1371
|
+
if (cutoff !== null && from < cutoff) {
|
|
1372
|
+
fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
|
|
1373
|
+
retainedBefore: cutoff,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async function datasetCoverageForRange(ctx, siteId, dataset, from, to) {
|
|
1378
|
+
if (dataset === "siteMap") {
|
|
1379
|
+
return siteMapDatasetCoverage(ctx, siteId, from, to);
|
|
1380
|
+
}
|
|
1381
|
+
// Vitals have no legacy fallback source; the rollup either covers the range
|
|
1382
|
+
// or the coverage states say how much of it exists.
|
|
1383
|
+
if (dataset === "vitals") {
|
|
1384
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1385
|
+
datasets: ["vitalRollups"],
|
|
1386
|
+
complete: false,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
const sourceOptions = dataset === "goals"
|
|
1390
|
+
? [["goalRollups"], ["goalCompletions"]]
|
|
1391
|
+
: dataset === "funnels"
|
|
1392
|
+
? [["funnelRollups"], ["funnelSteps"]]
|
|
1393
|
+
: dataset === "affiliates"
|
|
1394
|
+
? [["affiliateRollups"], ["affiliateStats", "sessions"]]
|
|
1395
|
+
: [[dataset]];
|
|
1396
|
+
const options = (await Promise.all(sourceOptions.map(async (sources) => {
|
|
1397
|
+
const rollupDataset = sources[0];
|
|
1398
|
+
const isRollup = rollupDataset.endsWith("Rollups");
|
|
1399
|
+
const complete = isRollup
|
|
1400
|
+
? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
|
|
1401
|
+
: false;
|
|
1402
|
+
if (isRollup && !complete)
|
|
1403
|
+
return null;
|
|
1404
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1405
|
+
datasets: sources,
|
|
1406
|
+
complete,
|
|
1407
|
+
generation: complete ? 1 : null,
|
|
1408
|
+
});
|
|
1409
|
+
}))).filter((option) => option !== null);
|
|
1410
|
+
return options.sort((left, right) => coverageRank(right.state) - coverageRank(left.state))[0];
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* The site map's coverage without reading its rows: it starts at the site's
|
|
1414
|
+
* `siteMapSince` and ends where retention cut it. The report itself may still
|
|
1415
|
+
* narrow the window when the range exceeds its row budget.
|
|
1416
|
+
*/
|
|
1417
|
+
async function siteMapDatasetCoverage(ctx, siteId, from, to) {
|
|
1418
|
+
const control = await ctx.db
|
|
1419
|
+
.query("analyticsCoverage")
|
|
1420
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1421
|
+
.unique();
|
|
1422
|
+
const since = control?.siteMapSince ?? null;
|
|
1423
|
+
const retainedBefore = control?.retained.find((item) => item.dataset === siteMapCoverageDataset)
|
|
1424
|
+
?.before ?? null;
|
|
1425
|
+
const unavailable = since === null ||
|
|
1426
|
+
to < since ||
|
|
1427
|
+
(retainedBefore !== null && to < retainedBefore);
|
|
1428
|
+
const state = unavailable
|
|
1429
|
+
? "unavailable"
|
|
1430
|
+
: from < Math.max(since, retainedBefore ?? 0)
|
|
1431
|
+
? "partial"
|
|
1432
|
+
: "complete";
|
|
1433
|
+
return {
|
|
1434
|
+
state,
|
|
1435
|
+
generation: 1,
|
|
1436
|
+
availableFrom: since,
|
|
1437
|
+
availableThrough: since === null ? null : to,
|
|
1438
|
+
retainedBefore,
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
async function sourceSetRange(ctx, siteId, sources) {
|
|
1442
|
+
const ranges = (await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))).filter((range) => range !== null);
|
|
1443
|
+
if (ranges.length === 0)
|
|
1444
|
+
return null;
|
|
1445
|
+
return {
|
|
1446
|
+
from: Math.min(...ranges.map((range) => range.from)),
|
|
1447
|
+
through: Math.max(...ranges.map((range) => range.through)),
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
async function sourceRange(ctx, siteId, source) {
|
|
1451
|
+
if (source === "overviewHour" || source === "overviewDay") {
|
|
1452
|
+
const granularity = source === "overviewHour" ? "hour" : "day";
|
|
1453
|
+
const [first, last] = await Promise.all([
|
|
1454
|
+
ctx.db
|
|
1455
|
+
.query("aggregateBuckets")
|
|
1456
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity))
|
|
1457
|
+
.order("asc")
|
|
1458
|
+
.take(1),
|
|
1459
|
+
ctx.db
|
|
1460
|
+
.query("aggregateBuckets")
|
|
1461
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity))
|
|
1462
|
+
.order("desc")
|
|
1463
|
+
.take(1),
|
|
1464
|
+
]);
|
|
1465
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1466
|
+
}
|
|
1467
|
+
if (source === "events") {
|
|
1468
|
+
const [first, last] = await Promise.all([
|
|
1469
|
+
ctx.db
|
|
1470
|
+
.query("eventBatches")
|
|
1471
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1472
|
+
.order("asc")
|
|
1473
|
+
.take(1),
|
|
1474
|
+
ctx.db
|
|
1475
|
+
.query("eventBatches")
|
|
1476
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1477
|
+
.order("desc")
|
|
1478
|
+
.take(1),
|
|
1479
|
+
]);
|
|
1480
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1481
|
+
}
|
|
1482
|
+
if (source === "sessions") {
|
|
1483
|
+
const [first, last] = await Promise.all([
|
|
1484
|
+
ctx.db
|
|
1485
|
+
.query("sessions")
|
|
1486
|
+
.withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
|
|
1487
|
+
.order("asc")
|
|
1488
|
+
.take(1),
|
|
1489
|
+
ctx.db
|
|
1490
|
+
.query("sessions")
|
|
1491
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
|
|
1492
|
+
.order("desc")
|
|
1493
|
+
.take(1),
|
|
1494
|
+
]);
|
|
1495
|
+
return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
|
|
1496
|
+
}
|
|
1497
|
+
if (source === "conversions") {
|
|
1498
|
+
const [first, last] = await Promise.all([
|
|
1499
|
+
ctx.db
|
|
1500
|
+
.query("conversions")
|
|
1501
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1502
|
+
.order("asc")
|
|
1503
|
+
.take(1),
|
|
1504
|
+
ctx.db
|
|
1505
|
+
.query("conversions")
|
|
1506
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1507
|
+
.order("desc")
|
|
1508
|
+
.take(1),
|
|
1509
|
+
]);
|
|
1510
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1511
|
+
}
|
|
1512
|
+
if (source === "goalCompletions") {
|
|
1513
|
+
const [first, last] = await Promise.all([
|
|
1514
|
+
ctx.db
|
|
1515
|
+
.query("goalCompletions")
|
|
1516
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1517
|
+
.order("asc")
|
|
1518
|
+
.take(1),
|
|
1519
|
+
ctx.db
|
|
1520
|
+
.query("goalCompletions")
|
|
1521
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1522
|
+
.order("desc")
|
|
1523
|
+
.take(1),
|
|
1524
|
+
]);
|
|
1525
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1526
|
+
}
|
|
1527
|
+
const table = source === "funnelSteps"
|
|
1528
|
+
? "funnelStepBuckets"
|
|
1529
|
+
: source === "affiliateStats"
|
|
1530
|
+
? "affiliateDailyStats"
|
|
1531
|
+
: source === "goalRollups"
|
|
1532
|
+
? "goalDailyRollups"
|
|
1533
|
+
: source === "funnelRollups"
|
|
1534
|
+
? "funnelDailyRollups"
|
|
1535
|
+
: source === "vitalRollups"
|
|
1536
|
+
? "vitalRollups"
|
|
1537
|
+
: "affiliateDailyRollups";
|
|
1538
|
+
const [first, last] = await Promise.all([
|
|
1539
|
+
ctx.db
|
|
1540
|
+
.query(table)
|
|
1541
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1542
|
+
.order("asc")
|
|
1543
|
+
.take(1),
|
|
1544
|
+
ctx.db
|
|
1545
|
+
.query(table)
|
|
1546
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1547
|
+
.order("desc")
|
|
1548
|
+
.take(1),
|
|
1549
|
+
]);
|
|
1550
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1551
|
+
}
|
|
1552
|
+
function bounds(from, through) {
|
|
1553
|
+
return from === undefined || through === undefined ? null : { from, through };
|
|
1554
|
+
}
|
|
1555
|
+
function coverageRank(state) {
|
|
1556
|
+
if (state === "complete")
|
|
1557
|
+
return 3;
|
|
1558
|
+
if (state === "retained")
|
|
1559
|
+
return 2;
|
|
1560
|
+
if (state === "partial")
|
|
1561
|
+
return 1;
|
|
1562
|
+
return 0;
|
|
1563
|
+
}
|
|
501
1564
|
function emptyTotals() {
|
|
502
1565
|
return {
|
|
503
1566
|
events: 0,
|
|
@@ -524,25 +1587,43 @@ function addMetrics(target, source) {
|
|
|
524
1587
|
}
|
|
525
1588
|
function validateSiteIds(siteIds) {
|
|
526
1589
|
const unique = [...new Set(siteIds)];
|
|
527
|
-
if (unique.length < 1 ||
|
|
1590
|
+
if (unique.length < 1 ||
|
|
1591
|
+
unique.length > MAX_REPORT_SITES ||
|
|
1592
|
+
unique.length !== siteIds.length) {
|
|
528
1593
|
fail("INVALID_ARGUMENT", `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`);
|
|
529
1594
|
}
|
|
530
1595
|
return unique;
|
|
531
1596
|
}
|
|
532
1597
|
function validateRange(from, to, maxDays) {
|
|
533
|
-
if (!Number.
|
|
534
|
-
!Number.
|
|
1598
|
+
if (!Number.isSafeInteger(from) ||
|
|
1599
|
+
!Number.isSafeInteger(to) ||
|
|
535
1600
|
from < 0 ||
|
|
536
|
-
to
|
|
537
|
-
to - from
|
|
538
|
-
fail("INVALID_ARGUMENT", `range must
|
|
1601
|
+
to < from ||
|
|
1602
|
+
to - from >= maxDays * DAY_MS) {
|
|
1603
|
+
fail("INVALID_ARGUMENT", `range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`);
|
|
539
1604
|
}
|
|
540
|
-
return { from
|
|
1605
|
+
return { from, to };
|
|
1606
|
+
}
|
|
1607
|
+
function isAlignedRange(from, to, intervalMs) {
|
|
1608
|
+
return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
|
|
541
1609
|
}
|
|
542
|
-
function
|
|
1610
|
+
function requireAlignedRange(from, to, intervalMs, report) {
|
|
1611
|
+
if (!isAlignedRange(from, to, intervalMs)) {
|
|
1612
|
+
fail("REPORT_INCOMPLETE", `${report} requires complete inclusive UTC bucket boundaries`, { from, to, intervalMs, timezone: "UTC" });
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
function rangeSemantics(from, to) {
|
|
1616
|
+
return {
|
|
1617
|
+
from,
|
|
1618
|
+
to,
|
|
1619
|
+
boundary: "inclusive",
|
|
1620
|
+
timezone: "UTC",
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
function parseKeysetPagination(options, scope) {
|
|
543
1624
|
validatePagination(options);
|
|
544
1625
|
if (options.cursor === null) {
|
|
545
|
-
return {
|
|
1626
|
+
return { numItems: options.numItems, scope, cursor: null };
|
|
546
1627
|
}
|
|
547
1628
|
let decoded;
|
|
548
1629
|
try {
|
|
@@ -552,31 +1633,120 @@ function parseManualPagination(options) {
|
|
|
552
1633
|
fail("INVALID_ARGUMENT", "pagination cursor is malformed");
|
|
553
1634
|
}
|
|
554
1635
|
if (!isPlainRecord(decoded) ||
|
|
555
|
-
decoded.v !==
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
decoded.
|
|
559
|
-
decoded.
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
1636
|
+
decoded.v !== 2 ||
|
|
1637
|
+
decoded.k !== scope.kind ||
|
|
1638
|
+
decoded.s !== scope.siteId ||
|
|
1639
|
+
decoded.n !== (scope.sessionId ?? null) ||
|
|
1640
|
+
decoded.f !== scope.from ||
|
|
1641
|
+
decoded.t !== scope.to ||
|
|
1642
|
+
typeof decoded.x !== "number" ||
|
|
1643
|
+
!Number.isFinite(decoded.x) ||
|
|
1644
|
+
decoded.x < scope.from ||
|
|
1645
|
+
decoded.x > scope.to ||
|
|
1646
|
+
typeof decoded.c !== "number" ||
|
|
1647
|
+
!Number.isFinite(decoded.c) ||
|
|
1648
|
+
decoded.c < 0) {
|
|
1649
|
+
fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
|
|
1650
|
+
}
|
|
1651
|
+
return {
|
|
1652
|
+
numItems: options.numItems,
|
|
1653
|
+
scope,
|
|
1654
|
+
cursor: {
|
|
1655
|
+
timestamp: decoded.x,
|
|
1656
|
+
creationTime: decoded.c,
|
|
1657
|
+
},
|
|
1658
|
+
};
|
|
563
1659
|
}
|
|
564
|
-
function
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1660
|
+
function parseJourneyPagination(options, scope) {
|
|
1661
|
+
validatePagination(options);
|
|
1662
|
+
if (options.cursor === null) {
|
|
1663
|
+
return { numItems: options.numItems, scope, cursor: null };
|
|
1664
|
+
}
|
|
1665
|
+
let decoded;
|
|
1666
|
+
try {
|
|
1667
|
+
decoded = JSON.parse(atob(options.cursor));
|
|
1668
|
+
}
|
|
1669
|
+
catch {
|
|
1670
|
+
fail("INVALID_ARGUMENT", "pagination cursor is malformed");
|
|
1671
|
+
}
|
|
1672
|
+
if (!isPlainRecord(decoded) ||
|
|
1673
|
+
decoded.v !== 4 ||
|
|
1674
|
+
decoded.k !== scope.kind ||
|
|
1675
|
+
decoded.s !== scope.siteId ||
|
|
1676
|
+
decoded.n !== scope.sessionId ||
|
|
1677
|
+
decoded.f !== scope.from ||
|
|
1678
|
+
decoded.t !== scope.to ||
|
|
1679
|
+
typeof decoded.x !== "number" ||
|
|
1680
|
+
!Number.isFinite(decoded.x) ||
|
|
1681
|
+
decoded.x < scope.from ||
|
|
1682
|
+
decoded.x > scope.to ||
|
|
1683
|
+
typeof decoded.c !== "number" ||
|
|
1684
|
+
!Number.isFinite(decoded.c) ||
|
|
1685
|
+
decoded.c < 0 ||
|
|
1686
|
+
typeof decoded.i !== "string" ||
|
|
1687
|
+
decoded.i.length === 0) {
|
|
1688
|
+
fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
|
|
1689
|
+
}
|
|
1690
|
+
return {
|
|
1691
|
+
numItems: options.numItems,
|
|
1692
|
+
scope,
|
|
1693
|
+
cursor: {
|
|
1694
|
+
timestamp: decoded.x,
|
|
1695
|
+
creationTime: decoded.c,
|
|
1696
|
+
id: decoded.i,
|
|
1697
|
+
},
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
function keysetPaginationResult(rows, pagination, cursorFor) {
|
|
1701
|
+
const page = rows.slice(0, pagination.numItems);
|
|
1702
|
+
const isDone = rows.length <= pagination.numItems;
|
|
1703
|
+
const next = page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
|
|
1704
|
+
return {
|
|
1705
|
+
page,
|
|
1706
|
+
isDone,
|
|
1707
|
+
continueCursor: btoa(JSON.stringify({
|
|
1708
|
+
v: 2,
|
|
1709
|
+
k: pagination.scope.kind,
|
|
1710
|
+
s: pagination.scope.siteId,
|
|
1711
|
+
n: pagination.scope.sessionId ?? null,
|
|
1712
|
+
f: pagination.scope.from,
|
|
1713
|
+
t: pagination.scope.to,
|
|
1714
|
+
x: next?.timestamp ?? pagination.scope.to,
|
|
1715
|
+
c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
|
|
1716
|
+
})),
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
function journeyPaginationResult(rows, pagination) {
|
|
1720
|
+
const page = rows.slice(0, pagination.numItems);
|
|
1721
|
+
const isDone = rows.length <= pagination.numItems;
|
|
1722
|
+
const last = page.at(-1);
|
|
1723
|
+
const next = last
|
|
1724
|
+
? {
|
|
1725
|
+
timestamp: last.timestamp,
|
|
1726
|
+
creationTime: last._creationTime,
|
|
1727
|
+
id: last._id,
|
|
1728
|
+
}
|
|
1729
|
+
: pagination.cursor;
|
|
569
1730
|
return {
|
|
570
1731
|
page,
|
|
571
1732
|
isDone,
|
|
572
1733
|
continueCursor: btoa(JSON.stringify({
|
|
573
|
-
v:
|
|
574
|
-
|
|
1734
|
+
v: 4,
|
|
1735
|
+
k: pagination.scope.kind,
|
|
1736
|
+
s: pagination.scope.siteId,
|
|
1737
|
+
n: pagination.scope.sessionId,
|
|
1738
|
+
f: pagination.scope.from,
|
|
1739
|
+
t: pagination.scope.to,
|
|
1740
|
+
x: next?.timestamp ?? pagination.scope.to,
|
|
1741
|
+
c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
|
|
1742
|
+
i: next?.id ?? "~",
|
|
575
1743
|
})),
|
|
576
1744
|
};
|
|
577
1745
|
}
|
|
578
1746
|
function validatePagination(options) {
|
|
579
|
-
if (!Number.isSafeInteger(options.numItems) ||
|
|
1747
|
+
if (!Number.isSafeInteger(options.numItems) ||
|
|
1748
|
+
options.numItems < 1 ||
|
|
1749
|
+
options.numItems > 100) {
|
|
580
1750
|
fail("INVALID_ARGUMENT", "pagination numItems must be an integer between 1 and 100");
|
|
581
1751
|
}
|
|
582
1752
|
if (options.maximumRowsRead !== undefined &&
|