@iann29/rastro 0.1.0-alpha.7 → 0.1.0-alpha.8

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 (57) hide show
  1. package/README.md +34 -16
  2. package/agent/integration.md +14 -6
  3. package/agent/manifest.json +9 -7
  4. package/agent/manifest.schema.json +8 -5
  5. package/dist/client/federation.d.ts +13 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +4 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +412 -10
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +55 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +68 -4
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/constants.d.ts +4 -0
  16. package/dist/component/constants.d.ts.map +1 -1
  17. package/dist/component/constants.js +11 -0
  18. package/dist/component/constants.js.map +1 -1
  19. package/dist/component/coverage.d.ts +1 -0
  20. package/dist/component/coverage.d.ts.map +1 -1
  21. package/dist/component/coverage.js +13 -2
  22. package/dist/component/coverage.js.map +1 -1
  23. package/dist/component/http.d.ts.map +1 -1
  24. package/dist/component/http.js +1 -0
  25. package/dist/component/http.js.map +1 -1
  26. package/dist/component/ingest.d.ts.map +1 -1
  27. package/dist/component/ingest.js +144 -2
  28. package/dist/component/ingest.js.map +1 -1
  29. package/dist/component/reports.d.ts +75 -1
  30. package/dist/component/reports.d.ts.map +1 -1
  31. package/dist/component/reports.js +275 -3
  32. package/dist/component/reports.js.map +1 -1
  33. package/dist/component/retention.d.ts +4 -4
  34. package/dist/component/retention.d.ts.map +1 -1
  35. package/dist/component/retention.js +12 -2
  36. package/dist/component/retention.js.map +1 -1
  37. package/dist/component/sanitize.d.ts +9 -0
  38. package/dist/component/sanitize.d.ts.map +1 -1
  39. package/dist/component/sanitize.js +25 -0
  40. package/dist/component/sanitize.js.map +1 -1
  41. package/dist/component/schema.d.ts +65 -3
  42. package/dist/component/schema.js +25 -0
  43. package/dist/component/schema.js.map +1 -1
  44. package/docs/federation-setup.md +2 -0
  45. package/docs/federation.md +4 -0
  46. package/docs/upgrading.md +53 -10
  47. package/llms.txt +2 -0
  48. package/package.json +1 -1
  49. package/src/component/_generated/component.ts +55 -1
  50. package/src/component/constants.ts +11 -0
  51. package/src/component/coverage.ts +12 -1
  52. package/src/component/http.ts +1 -0
  53. package/src/component/ingest.ts +227 -0
  54. package/src/component/reports.ts +364 -2
  55. package/src/component/retention.ts +16 -1
  56. package/src/component/sanitize.ts +31 -0
  57. package/src/component/schema.ts +26 -0
@@ -20,10 +20,13 @@ import {
20
20
  MAX_FUNNELS_PER_SITE,
21
21
  MAX_FUNNEL_STEPS,
22
22
  MAX_GOALS_PER_SITE,
23
+ MAX_SITE_MAP_ROUTES_PER_DAY,
23
24
  RATE_LIMIT_WINDOW_MS,
24
25
  REPORT_ROLLUP_SHARDS,
26
+ ROUTE_TRANSITION_SLOTS,
25
27
  SECONDARY_AGGREGATE_SHARDS,
26
28
  SITE_INGEST_SHARDS,
29
+ SITE_MAP_OTHER_ROUTE,
27
30
  VITAL_PAGE_SLOTS,
28
31
  } from "./constants.js";
29
32
  import {
@@ -57,6 +60,7 @@ import {
57
60
  cleanString,
58
61
  isValidMoney,
59
62
  measureBatchBytes,
63
+ normalizeRoute,
60
64
  originAllowed,
61
65
  sanitizeContext,
62
66
  sanitizeCurrency,
@@ -153,11 +157,25 @@ type VitalRollupDelta = {
153
157
  { count: number; sum: number; histogram: number[] }
154
158
  >;
155
159
  };
160
+ type RouteRollupDelta = {
161
+ siteId: Id<"sites">;
162
+ bucketStart: number;
163
+ shard: number;
164
+ route: string;
165
+ pageviews: number;
166
+ entries: number;
167
+ continued: number;
168
+ dwellMs: number;
169
+ dwellSamples: number;
170
+ visitorSketch: VisitorSketch;
171
+ outgoing: Map<string, number>;
172
+ };
156
173
  type ReportRollupDeltas = {
157
174
  goals: Map<string, GoalRollupDelta>;
158
175
  funnels: Map<string, FunnelRollupDelta>;
159
176
  affiliates: Map<string, AffiliateRollupDelta>;
160
177
  vitals: Map<string, VitalRollupDelta>;
178
+ routes: Map<string, RouteRollupDelta>;
161
179
  };
162
180
  type AttributionSnapshot = {
163
181
  affiliateId: Id<"affiliates">;
@@ -766,6 +784,17 @@ async function accountEvent(
766
784
  input.batchState,
767
785
  )
768
786
  : await accountTrustedSession(ctx, input.existingSession, event);
787
+ if (input.recordTelemetry && event.type === "pageview") {
788
+ // `existingSession` is the snapshot from before `upsertSession`, so it
789
+ // still carries the route the visitor is leaving.
790
+ foldRouteRollups(
791
+ input.reportRollupDeltas,
792
+ input.siteId,
793
+ event,
794
+ session,
795
+ input.existingSession,
796
+ );
797
+ }
769
798
 
770
799
  const matchedGoalKeys: string[] = [];
771
800
  const matchingGoals = input.goals.filter(
@@ -979,6 +1008,14 @@ async function upsertSession(
979
1008
  eventCount: existing.eventCount + 1,
980
1009
  pageviewCount:
981
1010
  existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
1011
+ ...(event.type === "pageview"
1012
+ ? {
1013
+ lastPageviewAt: Math.max(
1014
+ existing.lastPageviewAt ?? 0,
1015
+ event.timestamp,
1016
+ ),
1017
+ }
1018
+ : {}),
982
1019
  durationMs: Math.max(0, lastSeenAt - startedAt),
983
1020
  conversionCount: existing.conversionCount + Number(financialConversion),
984
1021
  revenueCents:
@@ -1077,6 +1114,7 @@ async function upsertSession(
1077
1114
  device: context.device ?? "unknown",
1078
1115
  eventCount: 1,
1079
1116
  pageviewCount: event.type === "pageview" ? 1 : 0,
1117
+ ...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
1080
1118
  durationMs: 0,
1081
1119
  conversionCount: Number(financialConversion),
1082
1120
  revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
@@ -1532,6 +1570,11 @@ async function updateLiveSession(
1532
1570
  device: context.device ?? existing.device,
1533
1571
  pageviewCount: session.pageviewCount,
1534
1572
  eventCount: session.eventCount,
1573
+ // The live map animates a visitor from the route they left to the one
1574
+ // they reached; a heartbeat or click on the same page is not a move.
1575
+ ...(event.type === "pageview" && event.path !== existing.path
1576
+ ? { previousPath: existing.path, movedAt: event.timestamp }
1577
+ : {}),
1535
1578
  ...(event.type === "heartbeat"
1536
1579
  ? {
1537
1580
  lastHeartbeatEventId: event.eventId,
@@ -1876,6 +1919,7 @@ function createReportRollupDeltas(): ReportRollupDeltas {
1876
1919
  funnels: new Map(),
1877
1920
  affiliates: new Map(),
1878
1921
  vitals: new Map(),
1922
+ routes: new Map(),
1879
1923
  };
1880
1924
  }
1881
1925
 
@@ -2062,6 +2106,185 @@ function foldVitalRollups(
2062
2106
  }
2063
2107
  }
2064
2108
 
2109
+ function foldRouteDelta(
2110
+ deltas: ReportRollupDeltas,
2111
+ siteId: Id<"sites">,
2112
+ route: string,
2113
+ timestamp: number,
2114
+ visitorId: string,
2115
+ apply: (delta: RouteRollupDelta) => void,
2116
+ ) {
2117
+ const coordinates = reportRollupCoordinates(
2118
+ siteId,
2119
+ `route:${route}`,
2120
+ timestamp,
2121
+ visitorId,
2122
+ );
2123
+ const current = deltas.routes.get(coordinates.key) ?? {
2124
+ siteId,
2125
+ bucketStart: coordinates.bucketStart,
2126
+ shard: coordinates.shard,
2127
+ route,
2128
+ pageviews: 0,
2129
+ entries: 0,
2130
+ continued: 0,
2131
+ dwellMs: 0,
2132
+ dwellSamples: 0,
2133
+ visitorSketch: createVisitorSketch(),
2134
+ outgoing: new Map<string, number>(),
2135
+ };
2136
+ apply(current);
2137
+ deltas.routes.set(coordinates.key, current);
2138
+ }
2139
+
2140
+ /**
2141
+ * One pageview feeds the site map three ways: the route it lands on gains a
2142
+ * view (and an entry when it opens the session), the route it leaves gains
2143
+ * the transition and the time spent there, and the session's entry route is
2144
+ * marked as continued the first time its visitor moves on — which is what
2145
+ * lets the report derive bounces and exits without a session-end hook.
2146
+ */
2147
+ function foldRouteRollups(
2148
+ deltas: ReportRollupDeltas,
2149
+ siteId: Id<"sites">,
2150
+ event: TrackerEvent,
2151
+ session: SessionState,
2152
+ existing: CachedDocument<"sessions"> | null,
2153
+ ) {
2154
+ const route = normalizeRoute(event.path);
2155
+ const priorPageviews = existing?.pageviewCount ?? 0;
2156
+ const firstPageview = existing === null || priorPageviews === 0;
2157
+ foldRouteDelta(
2158
+ deltas,
2159
+ siteId,
2160
+ route,
2161
+ event.timestamp,
2162
+ event.visitorId,
2163
+ (delta) => {
2164
+ delta.pageviews += 1;
2165
+ if (firstPageview) delta.entries += 1;
2166
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2167
+ },
2168
+ );
2169
+ // Out-of-order telemetry keeps its view but cannot say where it came from.
2170
+ if (existing === null || firstPageview) return;
2171
+ if (event.timestamp < existing.lastSeenAt) return;
2172
+ const previous = normalizeRoute(existing.exitPath);
2173
+ const lastPageviewAt = existing.lastPageviewAt;
2174
+ foldRouteDelta(
2175
+ deltas,
2176
+ siteId,
2177
+ previous,
2178
+ event.timestamp,
2179
+ event.visitorId,
2180
+ (delta) => {
2181
+ delta.outgoing.set(route, (delta.outgoing.get(route) ?? 0) + 1);
2182
+ if (lastPageviewAt !== undefined && event.timestamp >= lastPageviewAt) {
2183
+ delta.dwellMs += event.timestamp - lastPageviewAt;
2184
+ delta.dwellSamples += 1;
2185
+ }
2186
+ },
2187
+ );
2188
+ if (priorPageviews === 1) {
2189
+ foldRouteDelta(
2190
+ deltas,
2191
+ siteId,
2192
+ normalizeRoute(existing.entryPath),
2193
+ existing.startedAt,
2194
+ event.visitorId,
2195
+ (delta) => {
2196
+ delta.continued += 1;
2197
+ },
2198
+ );
2199
+ }
2200
+ }
2201
+
2202
+ async function upsertRouteRollup(
2203
+ ctx: MutationCtx,
2204
+ delta: RouteRollupDelta,
2205
+ dayRowCounts: Map<string, number>,
2206
+ ) {
2207
+ const existing = await ctx.db
2208
+ .query("routeDailyRollups")
2209
+ .withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
2210
+ range
2211
+ .eq("siteId", delta.siteId)
2212
+ .eq("route", delta.route)
2213
+ .eq("bucketStart", delta.bucketStart)
2214
+ .eq("shard", delta.shard),
2215
+ )
2216
+ .unique();
2217
+ if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
2218
+ // A new route row spends one bounded read per batch to respect the daily
2219
+ // cap; past it, the day's remaining routes fold into "(other)".
2220
+ const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
2221
+ const dayKey = `${delta.siteId}:${delta.bucketStart}`;
2222
+ let dayRows = dayRowCounts.get(dayKey);
2223
+ if (dayRows === undefined) {
2224
+ dayRows = (
2225
+ await ctx.db
2226
+ .query("routeDailyRollups")
2227
+ .withIndex("by_siteId_and_bucketStart", (range) =>
2228
+ range
2229
+ .eq("siteId", delta.siteId)
2230
+ .eq("bucketStart", delta.bucketStart),
2231
+ )
2232
+ .take(capRows + 1)
2233
+ ).length;
2234
+ }
2235
+ if (dayRows >= capRows) {
2236
+ await upsertRouteRollup(
2237
+ ctx,
2238
+ { ...delta, route: SITE_MAP_OTHER_ROUTE },
2239
+ dayRowCounts,
2240
+ );
2241
+ return;
2242
+ }
2243
+ dayRowCounts.set(dayKey, dayRows + 1);
2244
+ }
2245
+ const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
2246
+ for (const [target, count] of delta.outgoing) {
2247
+ let entry = outgoing.find((candidate) => candidate.route === target);
2248
+ if (!entry) {
2249
+ const named = outgoing.filter(
2250
+ (candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
2251
+ ).length;
2252
+ const route =
2253
+ target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
2254
+ ? SITE_MAP_OTHER_ROUTE
2255
+ : target;
2256
+ entry = outgoing.find((candidate) => candidate.route === route);
2257
+ if (!entry) {
2258
+ entry = { route, count: 0 };
2259
+ outgoing.push(entry);
2260
+ }
2261
+ }
2262
+ entry.count += count;
2263
+ }
2264
+ const fields = {
2265
+ siteId: delta.siteId,
2266
+ bucketStart: delta.bucketStart,
2267
+ shard: delta.shard,
2268
+ generation: 1 as const,
2269
+ route: delta.route,
2270
+ pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
2271
+ entries: (existing?.entries ?? 0) + delta.entries,
2272
+ continued: (existing?.continued ?? 0) + delta.continued,
2273
+ dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
2274
+ dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
2275
+ visitorSketch: unionVisitorSketches(
2276
+ existing?.visitorSketch ?? createVisitorSketch(),
2277
+ delta.visitorSketch,
2278
+ ),
2279
+ outgoing,
2280
+ };
2281
+ if (existing) {
2282
+ await ctx.db.replace("routeDailyRollups", existing._id, fields);
2283
+ } else {
2284
+ await ctx.db.insert("routeDailyRollups", fields);
2285
+ }
2286
+ }
2287
+
2065
2288
  async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
2066
2289
  const existing = await ctx.db
2067
2290
  .query("vitalRollups")
@@ -2233,6 +2456,10 @@ async function flushReportRollups(
2233
2456
  await ctx.db.insert("affiliateDailyRollups", fields);
2234
2457
  }
2235
2458
  }
2459
+ const routeDayRowCounts = new Map<string, number>();
2460
+ for (const delta of deltas.routes.values()) {
2461
+ await upsertRouteRollup(ctx, delta, routeDayRowCounts);
2462
+ }
2236
2463
  for (const delta of deltas.vitals.values()) {
2237
2464
  await upsertVitalRollup(ctx, delta);
2238
2465
  }