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

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 (73) hide show
  1. package/README.md +60 -32
  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 +419 -17
  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 +71 -7
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/constants.d.ts +6 -1
  16. package/dist/component/constants.d.ts.map +1 -1
  17. package/dist/component/constants.js +13 -1
  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/eventStore.d.ts +1 -1
  24. package/dist/component/http.d.ts.map +1 -1
  25. package/dist/component/http.js +2 -0
  26. package/dist/component/http.js.map +1 -1
  27. package/dist/component/ingest.d.ts +1 -1
  28. package/dist/component/ingest.d.ts.map +1 -1
  29. package/dist/component/ingest.js +173 -3
  30. package/dist/component/ingest.js.map +1 -1
  31. package/dist/component/reports.d.ts +77 -3
  32. package/dist/component/reports.d.ts.map +1 -1
  33. package/dist/component/reports.js +275 -3
  34. package/dist/component/reports.js.map +1 -1
  35. package/dist/component/retention.d.ts +4 -4
  36. package/dist/component/retention.d.ts.map +1 -1
  37. package/dist/component/retention.js +12 -2
  38. package/dist/component/retention.js.map +1 -1
  39. package/dist/component/sanitize.d.ts +9 -0
  40. package/dist/component/sanitize.d.ts.map +1 -1
  41. package/dist/component/sanitize.js +25 -0
  42. package/dist/component/sanitize.js.map +1 -1
  43. package/dist/component/schema.d.ts +69 -7
  44. package/dist/component/schema.js +25 -0
  45. package/dist/component/schema.js.map +1 -1
  46. package/dist/component/validators.d.ts +7 -7
  47. package/dist/component/validators.d.ts.map +1 -1
  48. package/dist/component/validators.js +1 -1
  49. package/dist/component/validators.js.map +1 -1
  50. package/dist/tracker/generated.d.ts +4 -4
  51. package/dist/tracker/generated.d.ts.map +1 -1
  52. package/dist/tracker/generated.js +4 -4
  53. package/dist/tracker/generated.js.map +1 -1
  54. package/dist/tracker/tracker.d.ts +1 -1
  55. package/dist/tracker/tracker.d.ts.map +1 -1
  56. package/dist/tracker/tracker.js +24 -2
  57. package/dist/tracker/tracker.js.map +1 -1
  58. package/dist/tracker.min.js +1 -1
  59. package/docs/federation-setup.md +2 -0
  60. package/docs/federation.md +4 -0
  61. package/docs/upgrading.md +91 -10
  62. package/llms.txt +3 -1
  63. package/package.json +1 -1
  64. package/src/component/_generated/component.ts +58 -1
  65. package/src/component/constants.ts +13 -1
  66. package/src/component/coverage.ts +12 -1
  67. package/src/component/http.ts +2 -0
  68. package/src/component/ingest.ts +278 -1
  69. package/src/component/reports.ts +364 -2
  70. package/src/component/retention.ts +16 -1
  71. package/src/component/sanitize.ts +31 -0
  72. package/src/component/schema.ts +26 -0
  73. package/src/component/validators.ts +1 -0
@@ -10,6 +10,7 @@ import {
10
10
  HOURLY_AGGREGATE_SHARDS,
11
11
  DIMENSION_SLOTS,
12
12
  HOUR_MS,
13
+ LIVE_LEAVE_GRACE_MS,
13
14
  LIVE_SESSION_TTL_MS,
14
15
  LIVE_SWEEP_INTERVAL_MS,
15
16
  MAX_BATCH_EVENTS,
@@ -20,10 +21,13 @@ import {
20
21
  MAX_FUNNELS_PER_SITE,
21
22
  MAX_FUNNEL_STEPS,
22
23
  MAX_GOALS_PER_SITE,
24
+ MAX_SITE_MAP_ROUTES_PER_DAY,
23
25
  RATE_LIMIT_WINDOW_MS,
24
26
  REPORT_ROLLUP_SHARDS,
27
+ ROUTE_TRANSITION_SLOTS,
25
28
  SECONDARY_AGGREGATE_SHARDS,
26
29
  SITE_INGEST_SHARDS,
30
+ SITE_MAP_OTHER_ROUTE,
27
31
  VITAL_PAGE_SLOTS,
28
32
  } from "./constants.js";
29
33
  import {
@@ -57,6 +61,7 @@ import {
57
61
  cleanString,
58
62
  isValidMoney,
59
63
  measureBatchBytes,
64
+ normalizeRoute,
60
65
  originAllowed,
61
66
  sanitizeContext,
62
67
  sanitizeCurrency,
@@ -153,11 +158,25 @@ type VitalRollupDelta = {
153
158
  { count: number; sum: number; histogram: number[] }
154
159
  >;
155
160
  };
161
+ type RouteRollupDelta = {
162
+ siteId: Id<"sites">;
163
+ bucketStart: number;
164
+ shard: number;
165
+ route: string;
166
+ pageviews: number;
167
+ entries: number;
168
+ continued: number;
169
+ dwellMs: number;
170
+ dwellSamples: number;
171
+ visitorSketch: VisitorSketch;
172
+ outgoing: Map<string, number>;
173
+ };
156
174
  type ReportRollupDeltas = {
157
175
  goals: Map<string, GoalRollupDelta>;
158
176
  funnels: Map<string, FunnelRollupDelta>;
159
177
  affiliates: Map<string, AffiliateRollupDelta>;
160
178
  vitals: Map<string, VitalRollupDelta>;
179
+ routes: Map<string, RouteRollupDelta>;
161
180
  };
162
181
  type AttributionSnapshot = {
163
182
  affiliateId: Id<"affiliates">;
@@ -297,7 +316,12 @@ export const ingestBatch = mutation({
297
316
  const bucketStart = eventBucketStart(event.timestamp);
298
317
  const groupKey = eventBatchKey(event.sessionId, bucketStart);
299
318
  requestGroups.add(groupKey);
300
- if (event.type === "heartbeat" || event.type === "vital") continue;
319
+ if (
320
+ event.type === "heartbeat" ||
321
+ event.type === "vital" ||
322
+ event.type === "leave"
323
+ )
324
+ continue;
301
325
  const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
302
326
  if (eventIds.has(event.eventId)) {
303
327
  duplicateInBatch.add(eventIndex);
@@ -404,6 +428,50 @@ export const ingestBatch = mutation({
404
428
  continue;
405
429
  }
406
430
 
431
+ if (event.type === "leave") {
432
+ // A departure only shortens live presence: it stores no telemetry,
433
+ // touches no aggregates, and never extends anything, so a replayed
434
+ // or late leave is inert. The read side filters by expiresAt and the
435
+ // sweep deletes the row, so the dashboard sees the exit in seconds.
436
+ if (!existingSession) {
437
+ rejected += 1;
438
+ continue;
439
+ }
440
+ if (
441
+ !(await consumeRateLimit(
442
+ ctx,
443
+ args.siteId,
444
+ event.sessionId,
445
+ eventByteCounts[eventIndex],
446
+ now,
447
+ sessionRateWindows,
448
+ ))
449
+ ) {
450
+ rejected += 1;
451
+ continue;
452
+ }
453
+ const liveState = await loadLiveSession(
454
+ ctx,
455
+ args.siteId,
456
+ event.sessionId,
457
+ batchState,
458
+ );
459
+ const liveSession = liveState.current;
460
+ if (liveSession) {
461
+ const expiresAt = Math.min(
462
+ liveSession.expiresAt,
463
+ now + LIVE_LEAVE_GRACE_MS,
464
+ );
465
+ if (expiresAt < liveSession.expiresAt) {
466
+ await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
467
+ liveState.current = { ...liveSession, expiresAt };
468
+ }
469
+ await ensureLiveSweep(ctx, args.siteId, now, batchState);
470
+ }
471
+ accepted += 1;
472
+ continue;
473
+ }
474
+
407
475
  if (event.type === "vital") {
408
476
  // A vital annotates telemetry its session already produced; it never
409
477
  // creates sessions, live presence, event batches, or aggregate counts.
@@ -766,6 +834,17 @@ async function accountEvent(
766
834
  input.batchState,
767
835
  )
768
836
  : await accountTrustedSession(ctx, input.existingSession, event);
837
+ if (input.recordTelemetry && event.type === "pageview") {
838
+ // `existingSession` is the snapshot from before `upsertSession`, so it
839
+ // still carries the route the visitor is leaving.
840
+ foldRouteRollups(
841
+ input.reportRollupDeltas,
842
+ input.siteId,
843
+ event,
844
+ session,
845
+ input.existingSession,
846
+ );
847
+ }
769
848
 
770
849
  const matchedGoalKeys: string[] = [];
771
850
  const matchingGoals = input.goals.filter(
@@ -979,6 +1058,14 @@ async function upsertSession(
979
1058
  eventCount: existing.eventCount + 1,
980
1059
  pageviewCount:
981
1060
  existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
1061
+ ...(event.type === "pageview"
1062
+ ? {
1063
+ lastPageviewAt: Math.max(
1064
+ existing.lastPageviewAt ?? 0,
1065
+ event.timestamp,
1066
+ ),
1067
+ }
1068
+ : {}),
982
1069
  durationMs: Math.max(0, lastSeenAt - startedAt),
983
1070
  conversionCount: existing.conversionCount + Number(financialConversion),
984
1071
  revenueCents:
@@ -1077,6 +1164,7 @@ async function upsertSession(
1077
1164
  device: context.device ?? "unknown",
1078
1165
  eventCount: 1,
1079
1166
  pageviewCount: event.type === "pageview" ? 1 : 0,
1167
+ ...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
1080
1168
  durationMs: 0,
1081
1169
  conversionCount: Number(financialConversion),
1082
1170
  revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
@@ -1532,6 +1620,11 @@ async function updateLiveSession(
1532
1620
  device: context.device ?? existing.device,
1533
1621
  pageviewCount: session.pageviewCount,
1534
1622
  eventCount: session.eventCount,
1623
+ // The live map animates a visitor from the route they left to the one
1624
+ // they reached; a heartbeat or click on the same page is not a move.
1625
+ ...(event.type === "pageview" && event.path !== existing.path
1626
+ ? { previousPath: existing.path, movedAt: event.timestamp }
1627
+ : {}),
1535
1628
  ...(event.type === "heartbeat"
1536
1629
  ? {
1537
1630
  lastHeartbeatEventId: event.eventId,
@@ -1876,6 +1969,7 @@ function createReportRollupDeltas(): ReportRollupDeltas {
1876
1969
  funnels: new Map(),
1877
1970
  affiliates: new Map(),
1878
1971
  vitals: new Map(),
1972
+ routes: new Map(),
1879
1973
  };
1880
1974
  }
1881
1975
 
@@ -2062,6 +2156,185 @@ function foldVitalRollups(
2062
2156
  }
2063
2157
  }
2064
2158
 
2159
+ function foldRouteDelta(
2160
+ deltas: ReportRollupDeltas,
2161
+ siteId: Id<"sites">,
2162
+ route: string,
2163
+ timestamp: number,
2164
+ visitorId: string,
2165
+ apply: (delta: RouteRollupDelta) => void,
2166
+ ) {
2167
+ const coordinates = reportRollupCoordinates(
2168
+ siteId,
2169
+ `route:${route}`,
2170
+ timestamp,
2171
+ visitorId,
2172
+ );
2173
+ const current = deltas.routes.get(coordinates.key) ?? {
2174
+ siteId,
2175
+ bucketStart: coordinates.bucketStart,
2176
+ shard: coordinates.shard,
2177
+ route,
2178
+ pageviews: 0,
2179
+ entries: 0,
2180
+ continued: 0,
2181
+ dwellMs: 0,
2182
+ dwellSamples: 0,
2183
+ visitorSketch: createVisitorSketch(),
2184
+ outgoing: new Map<string, number>(),
2185
+ };
2186
+ apply(current);
2187
+ deltas.routes.set(coordinates.key, current);
2188
+ }
2189
+
2190
+ /**
2191
+ * One pageview feeds the site map three ways: the route it lands on gains a
2192
+ * view (and an entry when it opens the session), the route it leaves gains
2193
+ * the transition and the time spent there, and the session's entry route is
2194
+ * marked as continued the first time its visitor moves on — which is what
2195
+ * lets the report derive bounces and exits without a session-end hook.
2196
+ */
2197
+ function foldRouteRollups(
2198
+ deltas: ReportRollupDeltas,
2199
+ siteId: Id<"sites">,
2200
+ event: TrackerEvent,
2201
+ session: SessionState,
2202
+ existing: CachedDocument<"sessions"> | null,
2203
+ ) {
2204
+ const route = normalizeRoute(event.path);
2205
+ const priorPageviews = existing?.pageviewCount ?? 0;
2206
+ const firstPageview = existing === null || priorPageviews === 0;
2207
+ foldRouteDelta(
2208
+ deltas,
2209
+ siteId,
2210
+ route,
2211
+ event.timestamp,
2212
+ event.visitorId,
2213
+ (delta) => {
2214
+ delta.pageviews += 1;
2215
+ if (firstPageview) delta.entries += 1;
2216
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2217
+ },
2218
+ );
2219
+ // Out-of-order telemetry keeps its view but cannot say where it came from.
2220
+ if (existing === null || firstPageview) return;
2221
+ if (event.timestamp < existing.lastSeenAt) return;
2222
+ const previous = normalizeRoute(existing.exitPath);
2223
+ const lastPageviewAt = existing.lastPageviewAt;
2224
+ foldRouteDelta(
2225
+ deltas,
2226
+ siteId,
2227
+ previous,
2228
+ event.timestamp,
2229
+ event.visitorId,
2230
+ (delta) => {
2231
+ delta.outgoing.set(route, (delta.outgoing.get(route) ?? 0) + 1);
2232
+ if (lastPageviewAt !== undefined && event.timestamp >= lastPageviewAt) {
2233
+ delta.dwellMs += event.timestamp - lastPageviewAt;
2234
+ delta.dwellSamples += 1;
2235
+ }
2236
+ },
2237
+ );
2238
+ if (priorPageviews === 1) {
2239
+ foldRouteDelta(
2240
+ deltas,
2241
+ siteId,
2242
+ normalizeRoute(existing.entryPath),
2243
+ existing.startedAt,
2244
+ event.visitorId,
2245
+ (delta) => {
2246
+ delta.continued += 1;
2247
+ },
2248
+ );
2249
+ }
2250
+ }
2251
+
2252
+ async function upsertRouteRollup(
2253
+ ctx: MutationCtx,
2254
+ delta: RouteRollupDelta,
2255
+ dayRowCounts: Map<string, number>,
2256
+ ) {
2257
+ const existing = await ctx.db
2258
+ .query("routeDailyRollups")
2259
+ .withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
2260
+ range
2261
+ .eq("siteId", delta.siteId)
2262
+ .eq("route", delta.route)
2263
+ .eq("bucketStart", delta.bucketStart)
2264
+ .eq("shard", delta.shard),
2265
+ )
2266
+ .unique();
2267
+ if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
2268
+ // A new route row spends one bounded read per batch to respect the daily
2269
+ // cap; past it, the day's remaining routes fold into "(other)".
2270
+ const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
2271
+ const dayKey = `${delta.siteId}:${delta.bucketStart}`;
2272
+ let dayRows = dayRowCounts.get(dayKey);
2273
+ if (dayRows === undefined) {
2274
+ dayRows = (
2275
+ await ctx.db
2276
+ .query("routeDailyRollups")
2277
+ .withIndex("by_siteId_and_bucketStart", (range) =>
2278
+ range
2279
+ .eq("siteId", delta.siteId)
2280
+ .eq("bucketStart", delta.bucketStart),
2281
+ )
2282
+ .take(capRows + 1)
2283
+ ).length;
2284
+ }
2285
+ if (dayRows >= capRows) {
2286
+ await upsertRouteRollup(
2287
+ ctx,
2288
+ { ...delta, route: SITE_MAP_OTHER_ROUTE },
2289
+ dayRowCounts,
2290
+ );
2291
+ return;
2292
+ }
2293
+ dayRowCounts.set(dayKey, dayRows + 1);
2294
+ }
2295
+ const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
2296
+ for (const [target, count] of delta.outgoing) {
2297
+ let entry = outgoing.find((candidate) => candidate.route === target);
2298
+ if (!entry) {
2299
+ const named = outgoing.filter(
2300
+ (candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
2301
+ ).length;
2302
+ const route =
2303
+ target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
2304
+ ? SITE_MAP_OTHER_ROUTE
2305
+ : target;
2306
+ entry = outgoing.find((candidate) => candidate.route === route);
2307
+ if (!entry) {
2308
+ entry = { route, count: 0 };
2309
+ outgoing.push(entry);
2310
+ }
2311
+ }
2312
+ entry.count += count;
2313
+ }
2314
+ const fields = {
2315
+ siteId: delta.siteId,
2316
+ bucketStart: delta.bucketStart,
2317
+ shard: delta.shard,
2318
+ generation: 1 as const,
2319
+ route: delta.route,
2320
+ pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
2321
+ entries: (existing?.entries ?? 0) + delta.entries,
2322
+ continued: (existing?.continued ?? 0) + delta.continued,
2323
+ dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
2324
+ dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
2325
+ visitorSketch: unionVisitorSketches(
2326
+ existing?.visitorSketch ?? createVisitorSketch(),
2327
+ delta.visitorSketch,
2328
+ ),
2329
+ outgoing,
2330
+ };
2331
+ if (existing) {
2332
+ await ctx.db.replace("routeDailyRollups", existing._id, fields);
2333
+ } else {
2334
+ await ctx.db.insert("routeDailyRollups", fields);
2335
+ }
2336
+ }
2337
+
2065
2338
  async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
2066
2339
  const existing = await ctx.db
2067
2340
  .query("vitalRollups")
@@ -2233,6 +2506,10 @@ async function flushReportRollups(
2233
2506
  await ctx.db.insert("affiliateDailyRollups", fields);
2234
2507
  }
2235
2508
  }
2509
+ const routeDayRowCounts = new Map<string, number>();
2510
+ for (const delta of deltas.routes.values()) {
2511
+ await upsertRouteRollup(ctx, delta, routeDayRowCounts);
2512
+ }
2236
2513
  for (const delta of deltas.vitals.values()) {
2237
2514
  await upsertVitalRollup(ctx, delta);
2238
2515
  }