@iann29/rastro 0.1.0-alpha.8 → 0.1.0

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 (105) hide show
  1. package/README.md +274 -75
  2. package/agent/integration.md +223 -65
  3. package/agent/manifest.json +34 -13
  4. package/agent/manifest.schema.json +53 -11
  5. package/dist/client/federation.d.ts +196 -11
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +160 -6
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/heat.d.ts +37 -0
  10. package/dist/client/heat.d.ts.map +1 -0
  11. package/dist/client/heat.js +43 -0
  12. package/dist/client/heat.js.map +1 -0
  13. package/dist/client/index.d.ts +448 -105
  14. package/dist/client/index.d.ts.map +1 -1
  15. package/dist/client/index.js +207 -17
  16. package/dist/client/index.js.map +1 -1
  17. package/dist/component/_generated/api.d.ts +6 -0
  18. package/dist/component/_generated/api.d.ts.map +1 -1
  19. package/dist/component/_generated/api.js.map +1 -1
  20. package/dist/component/_generated/component.d.ts +53 -16
  21. package/dist/component/_generated/component.d.ts.map +1 -1
  22. package/dist/component/constants.d.ts +5 -1
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +8 -1
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +9 -0
  27. package/dist/component/coverage.d.ts.map +1 -1
  28. package/dist/component/coverage.js +36 -6
  29. package/dist/component/coverage.js.map +1 -1
  30. package/dist/component/eventStore.d.ts +1 -1
  31. package/dist/component/geo.d.ts +2 -0
  32. package/dist/component/geo.d.ts.map +1 -1
  33. package/dist/component/geo.js +56 -19
  34. package/dist/component/geo.js.map +1 -1
  35. package/dist/component/http.d.ts.map +1 -1
  36. package/dist/component/http.js +49 -27
  37. package/dist/component/http.js.map +1 -1
  38. package/dist/component/ingest.d.ts +1 -1
  39. package/dist/component/ingest.d.ts.map +1 -1
  40. package/dist/component/ingest.js +252 -305
  41. package/dist/component/ingest.js.map +1 -1
  42. package/dist/component/localTime.d.ts +25 -0
  43. package/dist/component/localTime.d.ts.map +1 -0
  44. package/dist/component/localTime.js +126 -0
  45. package/dist/component/localTime.js.map +1 -0
  46. package/dist/component/reports.d.ts +46 -15
  47. package/dist/component/reports.d.ts.map +1 -1
  48. package/dist/component/reports.js +193 -38
  49. package/dist/component/reports.js.map +1 -1
  50. package/dist/component/retention.js +26 -8
  51. package/dist/component/retention.js.map +1 -1
  52. package/dist/component/rollupStore.d.ts +320 -0
  53. package/dist/component/rollupStore.d.ts.map +1 -0
  54. package/dist/component/rollupStore.js +596 -0
  55. package/dist/component/rollupStore.js.map +1 -0
  56. package/dist/component/rollups.d.ts +20 -0
  57. package/dist/component/rollups.d.ts.map +1 -0
  58. package/dist/component/rollups.js +73 -0
  59. package/dist/component/rollups.js.map +1 -0
  60. package/dist/component/sanitize.d.ts +5 -0
  61. package/dist/component/sanitize.d.ts.map +1 -1
  62. package/dist/component/sanitize.js +15 -0
  63. package/dist/component/sanitize.js.map +1 -1
  64. package/dist/component/schema.d.ts +349 -15
  65. package/dist/component/schema.js +25 -0
  66. package/dist/component/schema.js.map +1 -1
  67. package/dist/component/sites.d.ts +12 -0
  68. package/dist/component/sites.d.ts.map +1 -1
  69. package/dist/component/sites.js +30 -0
  70. package/dist/component/sites.js.map +1 -1
  71. package/dist/component/validators.d.ts +41 -20
  72. package/dist/component/validators.d.ts.map +1 -1
  73. package/dist/component/validators.js +24 -3
  74. package/dist/component/validators.js.map +1 -1
  75. package/dist/tracker/generated.d.ts +7 -4
  76. package/dist/tracker/generated.d.ts.map +1 -1
  77. package/dist/tracker/generated.js +7 -4
  78. package/dist/tracker/generated.js.map +1 -1
  79. package/dist/tracker/tracker.d.ts +1 -1
  80. package/dist/tracker/tracker.d.ts.map +1 -1
  81. package/dist/tracker/tracker.js +24 -2
  82. package/dist/tracker/tracker.js.map +1 -1
  83. package/dist/tracker.min.js +1 -1
  84. package/docs/federation-setup.md +104 -27
  85. package/docs/federation.md +168 -31
  86. package/docs/upgrading.md +191 -16
  87. package/llms.txt +9 -7
  88. package/package.json +2 -1
  89. package/src/component/_generated/api.ts +6 -0
  90. package/src/component/_generated/component.ts +50 -12
  91. package/src/component/constants.ts +8 -1
  92. package/src/component/coverage.ts +46 -6
  93. package/src/component/geo.ts +82 -28
  94. package/src/component/http.ts +62 -26
  95. package/src/component/ingest.ts +378 -440
  96. package/src/component/localTime.ts +167 -0
  97. package/src/component/reports.ts +261 -39
  98. package/src/component/retention.ts +25 -7
  99. package/src/component/rollupStore.ts +799 -0
  100. package/src/component/rollups.ts +82 -0
  101. package/src/component/sanitize.ts +14 -0
  102. package/src/component/schema.ts +35 -0
  103. package/src/component/sites.ts +32 -0
  104. package/src/component/validators.ts +28 -1
  105. package/src/tracker/generated.ts +12 -0
@@ -15,6 +15,7 @@ import {
15
15
  import { eventBatchKey, eventBucketStart } from "./eventStore.js";
16
16
  import { isPlainRecord } from "./guards.js";
17
17
  import { sanitizeContext, stableHash } from "./sanitize.js";
18
+ import { localDayStart, localDayTimezone } from "./localTime.js";
18
19
  import { trackerEventValidator, type IngestContext } from "./validators.js";
19
20
 
20
21
  export type GeoIpProvider = "ipinfo" | "ipwhois";
@@ -55,6 +56,7 @@ const geoAggregateEventValidator = v.object({
55
56
  sessionId: v.string(),
56
57
  timestamp: v.number(),
57
58
  priorCountry: v.string(),
59
+ pageview: v.optional(v.boolean()),
58
60
  });
59
61
 
60
62
  export const probe = internalAction({
@@ -132,7 +134,10 @@ export const claimEnrichment = internalMutation({
132
134
  if (groups.size > MAX_BATCH_EVENT_GROUPS) {
133
135
  return { sessionIds: [], events: [] };
134
136
  }
135
- const persistedEvents = new Map<string, Map<string, string | undefined>>();
137
+ const persistedEvents = new Map<
138
+ string,
139
+ Map<string, { country: string | undefined; pageview: boolean }>
140
+ >();
136
141
  await Promise.all(
137
142
  [...groups].map(async ([key, group]) => {
138
143
  const batches = await ctx.db
@@ -154,7 +159,10 @@ export const claimEnrichment = internalMutation({
154
159
  (event) =>
155
160
  [
156
161
  event.eventId,
157
- event.aggregateCountry ?? event.country,
162
+ {
163
+ country: event.aggregateCountry ?? event.country,
164
+ pageview: event.type === "pageview",
165
+ },
158
166
  ] as const,
159
167
  ),
160
168
  ),
@@ -230,6 +238,7 @@ export const claimEnrichment = internalMutation({
230
238
  sessionId: string;
231
239
  timestamp: number;
232
240
  priorCountry: string;
241
+ pageview: boolean;
233
242
  }> = [];
234
243
  for (const event of args.events) {
235
244
  const key = `${eventBatchKey(event.sessionId, eventBucketStart(event.timestamp))}:${event.eventId}`;
@@ -245,14 +254,15 @@ export const claimEnrichment = internalMutation({
245
254
  continue;
246
255
  }
247
256
  seenEvents.add(key);
248
- const priorCountry = persistedEvents
257
+ const persisted = persistedEvents
249
258
  .get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
250
259
  ?.get(event.eventId);
251
- if (priorCountry === undefined) continue;
260
+ if (persisted?.country === undefined) continue;
252
261
  correctionEvents.push({
253
262
  sessionId: event.sessionId,
254
263
  timestamp: event.timestamp,
255
- priorCountry,
264
+ priorCountry: persisted.country,
265
+ pageview: persisted.pageview,
256
266
  });
257
267
  }
258
268
  return { sessionIds, events: correctionEvents };
@@ -276,7 +286,10 @@ export const applyEnrichment = internalMutation({
276
286
  return 0;
277
287
  }
278
288
  const context = sanitizeContext(args.context);
289
+ const site = await ctx.db.get("sites", args.siteId);
290
+ const timezone = localDayTimezone(site?.timezone);
279
291
  const sessionCountries = new Map<string, string>();
292
+ const sessionStarts = new Map<string, number>();
280
293
  let updated = 0;
281
294
  for (const sessionId of new Set(args.sessionIds)) {
282
295
  const session = await ctx.db
@@ -288,6 +301,7 @@ export const applyEnrichment = internalMutation({
288
301
  if (!session || session.geoLookupAttemptedAt === undefined) continue;
289
302
  const sessionCountry = session.country ?? context.country;
290
303
  if (sessionCountry) sessionCountries.set(sessionId, sessionCountry);
304
+ sessionStarts.set(sessionId, session.startedAt);
291
305
  const sessionGeo = missingGeo(session, context);
292
306
  if (Object.keys(sessionGeo).length > 0) {
293
307
  await ctx.db.patch("sessions", session._id, sessionGeo);
@@ -312,6 +326,8 @@ export const applyEnrichment = internalMutation({
312
326
  args.siteId,
313
327
  args.events,
314
328
  sessionCountries,
329
+ sessionStarts,
330
+ timezone,
315
331
  );
316
332
  }
317
333
  return updated;
@@ -546,24 +562,34 @@ async function correctCountryAggregates(
546
562
  sessionId: string;
547
563
  timestamp: number;
548
564
  priorCountry: string;
565
+ pageview?: boolean;
549
566
  }>,
550
567
  sessionCountries: Map<string, string>,
568
+ sessionStarts: Map<string, number>,
569
+ timezone: string | undefined,
551
570
  ): Promise<void> {
552
571
  const corrections = new Map<
553
572
  string,
554
573
  {
555
- granularity: "hour" | "day";
574
+ granularity: "hour" | "day" | "localDay";
556
575
  bucketStart: number;
557
576
  shard: number;
558
- moves: Map<string, { from: string; to: string; count: number }>;
577
+ moves: Map<string, CountryMove>;
559
578
  }
560
579
  >();
580
+ const granularities: ReadonlyArray<"hour" | "day" | "localDay"> = timezone
581
+ ? ["hour", "day", "localDay"]
582
+ : ["hour", "day"];
561
583
  for (const event of events) {
562
584
  const country = sessionCountries.get(event.sessionId);
563
585
  if (!country || country === event.priorCountry) continue;
564
- for (const granularity of ["hour", "day"] as const) {
565
- const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
566
- const bucketStart = Math.floor(event.timestamp / interval) * interval;
586
+ for (const granularity of granularities) {
587
+ const bucketStart =
588
+ granularity === "localDay" && timezone
589
+ ? localDayStart(event.timestamp, timezone)
590
+ : Math.floor(
591
+ event.timestamp / (granularity === "hour" ? HOUR_MS : DAY_MS),
592
+ ) * (granularity === "hour" ? HOUR_MS : DAY_MS);
567
593
  const shard =
568
594
  stableHash(event.sessionId) %
569
595
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
@@ -572,15 +598,23 @@ async function correctCountryAggregates(
572
598
  granularity,
573
599
  bucketStart,
574
600
  shard,
575
- moves: new Map<string, { from: string; to: string; count: number }>(),
601
+ moves: new Map<string, CountryMove>(),
576
602
  };
577
603
  const moveKey = `${event.priorCountry}:${country}`;
578
604
  const move = correction.moves.get(moveKey) ?? {
579
605
  from: event.priorCountry,
580
606
  to: country,
581
607
  count: 0,
608
+ pageviews: 0,
609
+ sessions: 0,
582
610
  };
583
611
  move.count += 1;
612
+ move.pageviews += Number(event.pageview === true);
613
+ // The session counter was folded with the session's first event, so it
614
+ // moves with the bucket that holds that event and no other.
615
+ move.sessions += Number(
616
+ sessionStarts.get(event.sessionId) === event.timestamp,
617
+ );
584
618
  correction.moves.set(moveKey, move);
585
619
  corrections.set(key, correction);
586
620
  }
@@ -602,12 +636,7 @@ async function correctCountryAggregates(
602
636
  let dimensions = bucket.dimensions;
603
637
  let changed = false;
604
638
  for (const move of correction.moves.values()) {
605
- const corrected = moveCountryCount(
606
- dimensions,
607
- move.from,
608
- move.to,
609
- move.count,
610
- );
639
+ const corrected = moveCountryCount(dimensions, move);
611
640
  if (corrected) {
612
641
  dimensions = corrected;
613
642
  changed = true;
@@ -619,11 +648,17 @@ async function correctCountryAggregates(
619
648
  }
620
649
  }
621
650
 
651
+ type CountryMove = {
652
+ from: string;
653
+ to: string;
654
+ count: number;
655
+ pageviews: number;
656
+ sessions: number;
657
+ };
658
+
622
659
  function moveCountryCount(
623
660
  current: Doc<"aggregateBuckets">["dimensions"],
624
- from: string,
625
- to: string,
626
- requested: number,
661
+ { from, to, ...requested }: CountryMove,
627
662
  ): Doc<"aggregateBuckets">["dimensions"] | undefined {
628
663
  const dimensions = current.map((slot) => ({ ...slot }));
629
664
  let sourceIndex = dimensions.findIndex(
@@ -636,9 +671,15 @@ function moveCountryCount(
636
671
  }
637
672
  if (sourceIndex < 0) return undefined;
638
673
  const source = dimensions[sourceIndex]!;
639
- const moved = Math.min(source.count, requested);
640
- if (moved <= 0) return undefined;
641
- source.count -= moved;
674
+ const moved = {
675
+ count: Math.min(source.count, requested.count),
676
+ pageviews: Math.min(source.pageviews ?? 0, requested.pageviews),
677
+ sessions: Math.min(source.sessions ?? 0, requested.sessions),
678
+ };
679
+ if (moved.count <= 0) return undefined;
680
+ source.count -= moved.count;
681
+ if (source.pageviews !== undefined) source.pageviews -= moved.pageviews;
682
+ if (source.sessions !== undefined) source.sessions -= moved.sessions;
642
683
  if (source.count === 0 && source.revenueCents === 0) {
643
684
  dimensions.splice(sourceIndex, 1);
644
685
  }
@@ -646,7 +687,7 @@ function moveCountryCount(
646
687
  (slot) => slot.type === "country" && slot.value === to,
647
688
  );
648
689
  if (target) {
649
- target.count += moved;
690
+ addCountryMove(target, moved);
650
691
  return dimensions;
651
692
  }
652
693
  const countrySlots = dimensions.filter((slot) => slot.type === "country");
@@ -658,14 +699,16 @@ function moveCountryCount(
658
699
  type: "country",
659
700
  slot,
660
701
  value: to,
661
- count: moved,
702
+ count: moved.count,
662
703
  revenueCents: 0,
704
+ pageviews: moved.pageviews,
705
+ sessions: moved.sessions,
663
706
  });
664
707
  return dimensions;
665
708
  }
666
709
  const overflow = countrySlots.find((slot) => slot.value === "(other)");
667
710
  if (overflow) {
668
- overflow.count += moved;
711
+ addCountryMove(overflow, moved);
669
712
  return dimensions;
670
713
  }
671
714
  const occupied = new Set(countrySlots.map((slot) => slot.slot));
@@ -676,8 +719,10 @@ function moveCountryCount(
676
719
  type: "country",
677
720
  slot,
678
721
  value: "(other)",
679
- count: moved,
722
+ count: moved.count,
680
723
  revenueCents: 0,
724
+ pageviews: moved.pageviews,
725
+ sessions: moved.sessions,
681
726
  });
682
727
  return dimensions;
683
728
  }
@@ -686,10 +731,19 @@ function moveCountryCount(
686
731
  if (candidate.count < replacement.count) replacement = candidate;
687
732
  }
688
733
  replacement.value = "(other)";
689
- replacement.count += moved;
734
+ addCountryMove(replacement, moved);
690
735
  return dimensions;
691
736
  }
692
737
 
738
+ function addCountryMove(
739
+ slot: Doc<"aggregateBuckets">["dimensions"][number],
740
+ moved: { count: number; pageviews: number; sessions: number },
741
+ ) {
742
+ slot.count += moved.count;
743
+ slot.pageviews = (slot.pageviews ?? 0) + moved.pageviews;
744
+ slot.sessions = (slot.sessions ?? 0) + moved.sessions;
745
+ }
746
+
693
747
  function missingGeo(existing: GeoContext, context: GeoContext): GeoContext {
694
748
  const coordinates =
695
749
  (existing.latitude === undefined || existing.longitude === undefined) &&
@@ -24,10 +24,13 @@ import { deriveVisitorKey, randomSecret } from "./identity.js";
24
24
  import { classifyClient, isKnownBot } from "./useragent.js";
25
25
  import type { TrackerEvent } from "./validators.js";
26
26
  import {
27
+ RASTRO_VERSION,
27
28
  TRACKER_GZIP_BASE64,
28
29
  TRACKER_GZIP_BYTES,
30
+ TRACKER_HASH,
29
31
  VITALS_GZIP_BASE64,
30
32
  VITALS_GZIP_BYTES,
33
+ VITALS_HASH,
31
34
  } from "../tracker/generated.js";
32
35
 
33
36
  const TRACKER_GZIP = Uint8Array.from(atob(TRACKER_GZIP_BASE64), (character) =>
@@ -37,6 +40,52 @@ const VITALS_GZIP = Uint8Array.from(atob(VITALS_GZIP_BASE64), (character) =>
37
40
  character.charCodeAt(0),
38
41
  );
39
42
 
43
+ // Five minutes fresh, then up to an hour of stale-while-revalidate: a visitor
44
+ // who returns after a deploy gets at most one stale load before the browser's
45
+ // background revalidation, and that revalidation is a 304 against the ETag.
46
+ const SCRIPT_CACHE_CONTROL = "public, max-age=300, stale-while-revalidate=3600";
47
+
48
+ /**
49
+ * Serves one pre-gzipped module with a strong ETag from its build-time content
50
+ * hash. A matching If-None-Match answers 304 with the same caching and CORS
51
+ * headers, so a revalidation costs a header exchange instead of the script.
52
+ */
53
+ function scriptResponse(
54
+ request: Request,
55
+ body: Uint8Array<ArrayBuffer>,
56
+ byteLength: number,
57
+ hash: string,
58
+ ): Response {
59
+ const etag = `"${hash}"`;
60
+ const headers = {
61
+ ETag: etag,
62
+ "Cache-Control": SCRIPT_CACHE_CONTROL,
63
+ "Access-Control-Allow-Origin": "*",
64
+ Vary: "Accept-Encoding",
65
+ };
66
+ if (etagMatches(request.headers.get("if-none-match"), etag)) {
67
+ return new Response(null, { status: 304, headers });
68
+ }
69
+ return new Response(body, {
70
+ status: 200,
71
+ headers: {
72
+ ...headers,
73
+ "Content-Type": "text/javascript; charset=utf-8",
74
+ "Content-Encoding": "gzip",
75
+ "Content-Length": String(byteLength),
76
+ },
77
+ });
78
+ }
79
+
80
+ /** RFC 9110 If-None-Match: a list of entity tags or "*", weak markers ignored. */
81
+ function etagMatches(header: string | null, etag: string): boolean {
82
+ if (!header) return false;
83
+ return header.split(",").some((candidate) => {
84
+ const value = candidate.trim().replace(/^W\//, "");
85
+ return value === "*" || value === etag;
86
+ });
87
+ }
88
+
40
89
  const http = httpRouter();
41
90
 
42
91
  http.route({
@@ -228,37 +277,17 @@ http.route({
228
277
  http.route({
229
278
  path: "/tracker.js",
230
279
  method: "GET",
231
- handler: httpAction(async () => {
232
- return new Response(TRACKER_GZIP, {
233
- status: 200,
234
- headers: {
235
- "Content-Type": "text/javascript; charset=utf-8",
236
- "Content-Encoding": "gzip",
237
- "Content-Length": String(TRACKER_GZIP_BYTES),
238
- "Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
239
- "Access-Control-Allow-Origin": "*",
240
- Vary: "Accept-Encoding",
241
- },
242
- });
243
- }),
280
+ handler: httpAction(async (_ctx, request) =>
281
+ scriptResponse(request, TRACKER_GZIP, TRACKER_GZIP_BYTES, TRACKER_HASH),
282
+ ),
244
283
  });
245
284
 
246
285
  http.route({
247
286
  path: "/vitals.js",
248
287
  method: "GET",
249
- handler: httpAction(async () => {
250
- return new Response(VITALS_GZIP, {
251
- status: 200,
252
- headers: {
253
- "Content-Type": "text/javascript; charset=utf-8",
254
- "Content-Encoding": "gzip",
255
- "Content-Length": String(VITALS_GZIP_BYTES),
256
- "Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
257
- "Access-Control-Allow-Origin": "*",
258
- Vary: "Accept-Encoding",
259
- },
260
- });
261
- }),
288
+ handler: httpAction(async (_ctx, request) =>
289
+ scriptResponse(request, VITALS_GZIP, VITALS_GZIP_BYTES, VITALS_HASH),
290
+ ),
262
291
  });
263
292
 
264
293
  http.route({
@@ -299,6 +328,12 @@ http.route({
299
328
  : "session",
300
329
  webVitals: "optIn",
301
330
  siteMap: "dailyRollup",
331
+ liveDepartures: "leaveBeacon",
332
+ },
333
+ tracker: {
334
+ version: RASTRO_VERSION,
335
+ hash: TRACKER_HASH,
336
+ vitalsHash: VITALS_HASH,
302
337
  },
303
338
  },
304
339
  200,
@@ -368,6 +403,7 @@ function parseEvent(value: unknown): TrackerEvent {
368
403
  type !== "custom" &&
369
404
  type !== "conversion" &&
370
405
  type !== "heartbeat" &&
406
+ type !== "leave" &&
371
407
  type !== "outbound" &&
372
408
  type !== "vital"
373
409
  ) {