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