@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.
Files changed (179) hide show
  1. package/README.md +693 -69
  2. package/agent/integration.md +801 -0
  3. package/agent/manifest.json +205 -0
  4. package/agent/manifest.schema.json +444 -0
  5. package/dist/client/federation.d.ts +381 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +274 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2673 -15
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +565 -33
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +22 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +315 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +28 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +44 -1
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +27 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +56 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +21 -9
  47. package/dist/component/eventStore.d.ts.map +1 -1
  48. package/dist/component/eventStore.js +142 -152
  49. package/dist/component/eventStore.js.map +1 -1
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +73 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +648 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +8 -5
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +281 -62
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +498 -95
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts.map +1 -1
  74. package/dist/component/live.js +7 -6
  75. package/dist/component/live.js.map +1 -1
  76. package/dist/component/localTime.d.ts +25 -0
  77. package/dist/component/localTime.d.ts.map +1 -0
  78. package/dist/component/localTime.js +126 -0
  79. package/dist/component/localTime.js.map +1 -0
  80. package/dist/component/reports.d.ts +266 -10
  81. package/dist/component/reports.d.ts.map +1 -1
  82. package/dist/component/reports.js +1243 -137
  83. package/dist/component/reports.js.map +1 -1
  84. package/dist/component/retention.d.ts +75 -1
  85. package/dist/component/retention.d.ts.map +1 -1
  86. package/dist/component/retention.js +561 -38
  87. package/dist/component/retention.js.map +1 -1
  88. package/dist/component/rollupStore.d.ts +320 -0
  89. package/dist/component/rollupStore.d.ts.map +1 -0
  90. package/dist/component/rollupStore.js +596 -0
  91. package/dist/component/rollupStore.js.map +1 -0
  92. package/dist/component/rollups.d.ts +20 -0
  93. package/dist/component/rollups.d.ts.map +1 -0
  94. package/dist/component/rollups.js +73 -0
  95. package/dist/component/rollups.js.map +1 -0
  96. package/dist/component/sanitize.d.ts +24 -1
  97. package/dist/component/sanitize.d.ts.map +1 -1
  98. package/dist/component/sanitize.js +96 -16
  99. package/dist/component/sanitize.js.map +1 -1
  100. package/dist/component/schema.d.ts +687 -65
  101. package/dist/component/schema.js +187 -20
  102. package/dist/component/schema.js.map +1 -1
  103. package/dist/component/sites.d.ts +12 -0
  104. package/dist/component/sites.d.ts.map +1 -1
  105. package/dist/component/sites.js +41 -7
  106. package/dist/component/sites.js.map +1 -1
  107. package/dist/component/useragent.d.ts +9 -0
  108. package/dist/component/useragent.d.ts.map +1 -0
  109. package/dist/component/useragent.js +152 -0
  110. package/dist/component/useragent.js.map +1 -0
  111. package/dist/component/validators.d.ts +124 -69
  112. package/dist/component/validators.d.ts.map +1 -1
  113. package/dist/component/validators.js +35 -14
  114. package/dist/component/validators.js.map +1 -1
  115. package/dist/component/visitors.d.ts +19 -0
  116. package/dist/component/visitors.d.ts.map +1 -0
  117. package/dist/component/visitors.js +86 -0
  118. package/dist/component/visitors.js.map +1 -0
  119. package/dist/component/vitals.d.ts +41 -0
  120. package/dist/component/vitals.d.ts.map +1 -0
  121. package/dist/component/vitals.js +115 -0
  122. package/dist/component/vitals.js.map +1 -0
  123. package/dist/react/index.d.ts.map +1 -1
  124. package/dist/react/index.js.map +1 -1
  125. package/dist/tracker/generated.d.ts +11 -4
  126. package/dist/tracker/generated.d.ts.map +1 -1
  127. package/dist/tracker/generated.js +11 -4
  128. package/dist/tracker/generated.js.map +1 -1
  129. package/dist/tracker/tracker.d.ts +1 -1
  130. package/dist/tracker/tracker.d.ts.map +1 -1
  131. package/dist/tracker/tracker.js +57 -20
  132. package/dist/tracker/tracker.js.map +1 -1
  133. package/dist/tracker/vitals.d.ts +10 -0
  134. package/dist/tracker/vitals.d.ts.map +1 -0
  135. package/dist/tracker/vitals.js +140 -0
  136. package/dist/tracker/vitals.js.map +1 -0
  137. package/dist/tracker.min.js +1 -1
  138. package/dist/vitals.min.js +1 -0
  139. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  140. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  141. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  142. package/docs/federation-setup.md +464 -0
  143. package/docs/federation.md +352 -0
  144. package/docs/upgrading.md +344 -0
  145. package/llms.txt +72 -0
  146. package/package.json +55 -11
  147. package/scripts/benchmark-ingest.mjs +175 -73
  148. package/scripts/generate-federation-keys.mjs +20 -0
  149. package/src/component/_generated/api.ts +22 -0
  150. package/src/component/_generated/component.ts +381 -4
  151. package/src/component/_generated/server.ts +4 -0
  152. package/src/component/affiliates.ts +20 -5
  153. package/src/component/cardinality.ts +117 -0
  154. package/src/component/constants.ts +44 -1
  155. package/src/component/convex.config.ts +11 -1
  156. package/src/component/coverage.ts +71 -0
  157. package/src/component/diagnostics.ts +65 -0
  158. package/src/component/errors.ts +2 -1
  159. package/src/component/eventStore.ts +217 -193
  160. package/src/component/funnels.ts +19 -16
  161. package/src/component/geo.ts +835 -0
  162. package/src/component/goals.ts +29 -21
  163. package/src/component/guards.ts +3 -1
  164. package/src/component/http.ts +404 -70
  165. package/src/component/identity.ts +74 -0
  166. package/src/component/ingest.ts +894 -188
  167. package/src/component/live.ts +13 -7
  168. package/src/component/localTime.ts +167 -0
  169. package/src/component/reports.ts +1872 -197
  170. package/src/component/retention.ts +788 -96
  171. package/src/component/rollupStore.ts +799 -0
  172. package/src/component/rollups.ts +82 -0
  173. package/src/component/sanitize.ts +144 -29
  174. package/src/component/schema.ts +217 -21
  175. package/src/component/sites.ts +59 -12
  176. package/src/component/useragent.ts +171 -0
  177. package/src/component/validators.ts +49 -14
  178. package/src/component/visitors.ts +116 -0
  179. 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 { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, } from "./eventStore.js";
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
- const firstBucket = Math.floor(from / intervalMs) * intervalMs;
118
- const lastBucket = Math.floor(to / intervalMs) * intervalMs;
119
- const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
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", interval)
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
- const top = (type) => [...dimensions.entries()]
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(([, value]) => value)
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
- topPages: top("page"),
171
- topCountries: top("country"),
172
- topDevices: top("device"),
173
- topBrowsers: top("browser"),
174
- topEvents: top("event"),
175
- topGoals: top("goal"),
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) || limit < 1 || limit > MAX_LIVE_VISITORS) {
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
- return keysetPaginationResult(rows, pagination, (row) => ({
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) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
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
- let overlappingSessionsRead = 0;
398
- for (const session of sessions) {
399
- const resultIsFull = rows.length >= limit;
400
- const cutoff = resultIsFull
401
- ? rows[limit - 1].timestamp
402
- : args.to;
403
- if (session.startedAt > cutoff)
404
- break;
405
- if (resultIsFull && ++overlappingSessionsRead > 8) {
406
- fail("LIMIT_EXCEEDED", "visitor journey has too many overlapping sessions", { maximumOverlappingSessions: 8 });
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 goals = await ctx.db
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
- const completions = await ctx.db
442
- .query("goalCompletions")
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
- for (const completion of completions.slice(0, 5_000)) {
450
- const key = String(completion.goalId);
451
- const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
452
- total.completions += 1;
453
- total.valueCents += completion.valueCents;
454
- totals.set(key, total);
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: completions.length > 5_000,
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({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() })),
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
- const funnels = await ctx.db
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
- for (const bucket of buckets.slice(0, 600)) {
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: buckets.length > 600,
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
- const affiliates = affiliateRows.slice(0, MAX_AFFILIATES_PER_SITE);
535
- const stats = await ctx.db
536
- .query("affiliateDailyStats")
537
- .withIndex("by_siteId_and_bucketStart", (range) => range
538
- .eq("siteId", args.siteId)
539
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
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
- for (const row of stats.slice(0, 5_000)) {
564
- const key = String(row.affiliateId);
565
- const total = totals.get(key) ?? {
566
- conversions: 0,
567
- revenueCents: 0,
568
- commissionCents: 0,
569
- };
570
- total.conversions += row.conversions;
571
- total.revenueCents += row.revenueCents;
572
- total.commissionCents += row.commissionCents;
573
- totals.set(key, total);
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: traffic?.visitors.size ?? 0,
1121
+ uniqueVisitors: rollups
1122
+ ? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
1123
+ : (traffic?.visitors.size ?? 0),
588
1124
  };
589
1125
  }),
590
- truncated: affiliateRows.length > MAX_AFFILIATES_PER_SITE ||
591
- stats.length > 5_000 ||
592
- sessions.length > 5_000,
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 || unique.length > MAX_REPORT_SITES || unique.length !== siteIds.length) {
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.isFinite(from) ||
629
- !Number.isFinite(to) ||
1710
+ if (!Number.isSafeInteger(from) ||
1711
+ !Number.isSafeInteger(to) ||
630
1712
  from < 0 ||
631
- to <= from ||
632
- to - from > maxDays * DAY_MS) {
633
- fail("INVALID_ARGUMENT", `range must be positive, ordered, and at most ${maxDays} days`);
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
- return { from: Math.trunc(from), to: Math.trunc(to) };
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) || options.numItems < 1 || options.numItems > 100) {
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 &&