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

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 (125) hide show
  1. package/README.md +82 -29
  2. package/agent/integration.md +17 -15
  3. package/agent/manifest.json +7 -5
  4. package/agent/manifest.schema.json +8 -5
  5. package/dist/client/federation.d.ts +34 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +17 -3
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +418 -21
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +191 -41
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +2 -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 +63 -7
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/affiliates.d.ts.map +1 -1
  19. package/dist/component/affiliates.js +6 -2
  20. package/dist/component/affiliates.js.map +1 -1
  21. package/dist/component/cardinality.d.ts.map +1 -1
  22. package/dist/component/cardinality.js.map +1 -1
  23. package/dist/component/constants.d.ts +7 -1
  24. package/dist/component/constants.d.ts.map +1 -1
  25. package/dist/component/constants.js +13 -1
  26. package/dist/component/constants.js.map +1 -1
  27. package/dist/component/diagnostics.d.ts.map +1 -1
  28. package/dist/component/diagnostics.js.map +1 -1
  29. package/dist/component/eventStore.d.ts +2 -1
  30. package/dist/component/eventStore.d.ts.map +1 -1
  31. package/dist/component/eventStore.js +13 -10
  32. package/dist/component/eventStore.js.map +1 -1
  33. package/dist/component/funnels.d.ts.map +1 -1
  34. package/dist/component/funnels.js +5 -3
  35. package/dist/component/funnels.js.map +1 -1
  36. package/dist/component/geo.d.ts.map +1 -1
  37. package/dist/component/geo.js +3 -2
  38. package/dist/component/geo.js.map +1 -1
  39. package/dist/component/goals.d.ts.map +1 -1
  40. package/dist/component/goals.js +8 -5
  41. package/dist/component/goals.js.map +1 -1
  42. package/dist/component/guards.d.ts.map +1 -1
  43. package/dist/component/guards.js.map +1 -1
  44. package/dist/component/http.d.ts.map +1 -1
  45. package/dist/component/http.js +51 -17
  46. package/dist/component/http.js.map +1 -1
  47. package/dist/component/ingest.d.ts +2 -1
  48. package/dist/component/ingest.d.ts.map +1 -1
  49. package/dist/component/ingest.js +143 -23
  50. package/dist/component/ingest.js.map +1 -1
  51. package/dist/component/live.d.ts.map +1 -1
  52. package/dist/component/live.js +1 -3
  53. package/dist/component/live.js.map +1 -1
  54. package/dist/component/reports.d.ts +60 -3
  55. package/dist/component/reports.d.ts.map +1 -1
  56. package/dist/component/reports.js +215 -49
  57. package/dist/component/reports.js.map +1 -1
  58. package/dist/component/retention.d.ts +4 -4
  59. package/dist/component/retention.d.ts.map +1 -1
  60. package/dist/component/retention.js +54 -22
  61. package/dist/component/retention.js.map +1 -1
  62. package/dist/component/sanitize.d.ts +11 -0
  63. package/dist/component/sanitize.d.ts.map +1 -1
  64. package/dist/component/sanitize.js +58 -11
  65. package/dist/component/sanitize.js.map +1 -1
  66. package/dist/component/schema.d.ts +47 -5
  67. package/dist/component/schema.js +21 -1
  68. package/dist/component/schema.js.map +1 -1
  69. package/dist/component/sites.d.ts.map +1 -1
  70. package/dist/component/sites.js +6 -6
  71. package/dist/component/sites.js.map +1 -1
  72. package/dist/component/validators.d.ts +14 -9
  73. package/dist/component/validators.d.ts.map +1 -1
  74. package/dist/component/validators.js +5 -1
  75. package/dist/component/validators.js.map +1 -1
  76. package/dist/component/visitors.d.ts.map +1 -1
  77. package/dist/component/visitors.js.map +1 -1
  78. package/dist/component/vitals.d.ts +41 -0
  79. package/dist/component/vitals.d.ts.map +1 -0
  80. package/dist/component/vitals.js +115 -0
  81. package/dist/component/vitals.js.map +1 -0
  82. package/dist/react/index.d.ts.map +1 -1
  83. package/dist/react/index.js.map +1 -1
  84. package/dist/tracker/generated.d.ts +8 -4
  85. package/dist/tracker/generated.d.ts.map +1 -1
  86. package/dist/tracker/generated.js +8 -4
  87. package/dist/tracker/generated.js.map +1 -1
  88. package/dist/tracker/tracker.d.ts.map +1 -1
  89. package/dist/tracker/tracker.js +3 -1
  90. package/dist/tracker/tracker.js.map +1 -1
  91. package/dist/tracker/vitals.d.ts +10 -0
  92. package/dist/tracker/vitals.d.ts.map +1 -0
  93. package/dist/tracker/vitals.js +140 -0
  94. package/dist/tracker/vitals.js.map +1 -0
  95. package/dist/tracker.min.js +1 -1
  96. package/dist/vitals.min.js +1 -0
  97. package/docs/benchmarks/2026-08-20-realistic.md +71 -71
  98. package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
  99. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  100. package/docs/upgrading.md +46 -8
  101. package/llms.txt +5 -1
  102. package/package.json +17 -3
  103. package/scripts/benchmark-ingest.mjs +101 -48
  104. package/src/component/_generated/api.ts +2 -0
  105. package/src/component/_generated/component.ts +69 -4
  106. package/src/component/affiliates.ts +20 -5
  107. package/src/component/cardinality.ts +8 -7
  108. package/src/component/constants.ts +13 -1
  109. package/src/component/diagnostics.ts +3 -2
  110. package/src/component/eventStore.ts +53 -46
  111. package/src/component/funnels.ts +19 -16
  112. package/src/component/geo.ts +16 -14
  113. package/src/component/goals.ts +29 -21
  114. package/src/component/guards.ts +3 -1
  115. package/src/component/http.ts +93 -54
  116. package/src/component/ingest.ts +320 -127
  117. package/src/component/live.ts +5 -4
  118. package/src/component/reports.ts +563 -219
  119. package/src/component/retention.ts +221 -98
  120. package/src/component/sanitize.ts +99 -29
  121. package/src/component/schema.ts +31 -5
  122. package/src/component/sites.ts +22 -11
  123. package/src/component/validators.ts +16 -7
  124. package/src/component/visitors.ts +16 -5
  125. package/src/component/vitals.ts +146 -0
@@ -19,18 +19,23 @@ import {
19
19
  resolveGeoIp,
20
20
  } from "./geo.js";
21
21
  import { isPlainRecord } from "./guards.js";
22
- import { sanitizeEvent } from "./sanitize.js";
22
+ import { alignClock, sanitizeEvent } from "./sanitize.js";
23
23
  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
27
  TRACKER_GZIP_BASE64,
28
28
  TRACKER_GZIP_BYTES,
29
+ VITALS_GZIP_BASE64,
30
+ VITALS_GZIP_BYTES,
29
31
  } from "../tracker/generated.js";
30
32
 
31
33
  const TRACKER_GZIP = Uint8Array.from(atob(TRACKER_GZIP_BASE64), (character) =>
32
34
  character.charCodeAt(0),
33
35
  );
36
+ const VITALS_GZIP = Uint8Array.from(atob(VITALS_GZIP_BASE64), (character) =>
37
+ character.charCodeAt(0),
38
+ );
34
39
 
35
40
  const http = httpRouter();
36
41
 
@@ -57,7 +62,7 @@ http.route({
57
62
  );
58
63
  }
59
64
 
60
- let payload: { siteId: string; events: TrackerEvent[] };
65
+ let payload: { siteId: string; events: TrackerEvent[]; sentAt?: number };
61
66
  try {
62
67
  const bytes = await readLimitedBody(request, MAX_BATCH_BYTES);
63
68
  const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
@@ -82,6 +87,12 @@ http.route({
82
87
  headers,
83
88
  );
84
89
  }
90
+ // The sender's clock says when its events happened, but a clock minutes
91
+ // off would keep the visitor out of the live view and file the traffic
92
+ // under another hour. The batch also says when it was sent, and that offset
93
+ // re-anchors every event here, before the transaction and the geo
94
+ // enrichment read them, so both see the same timestamps.
95
+ const events = alignClock(payload.events, payload.sentAt, Date.now());
85
96
 
86
97
  const trustProxy = env.RASTRO_TRUST_PROXY === "true";
87
98
  const metadata = await readRequestMetadata(ctx);
@@ -94,14 +105,14 @@ http.route({
94
105
  // counter shape the tracker expects and names the reason in a header.
95
106
  if (isKnownBot(userAgent)) {
96
107
  return json(
97
- { accepted: 0, duplicates: 0, rejected: payload.events.length },
108
+ { accepted: 0, duplicates: 0, rejected: events.length },
98
109
  200,
99
110
  { ...headers, "X-Rastro-Rejected": "bot" },
100
111
  );
101
112
  }
102
113
  // Anonymous batches (visitorId === sessionId) get a salted daily key so
103
114
  // repeat visits count once; the IP itself stops here.
104
- const anonymous = payload.events.some(
115
+ const anonymous = events.some(
105
116
  (event) => event.visitorId === event.sessionId,
106
117
  );
107
118
  const visitorKey =
@@ -124,7 +135,7 @@ http.route({
124
135
  const result = await ctx.runMutation(api.ingest.ingestBatch, {
125
136
  siteId: payload.siteId as Id<"sites">,
126
137
  origin,
127
- events: payload.events,
138
+ events,
128
139
  context,
129
140
  });
130
141
  if (
@@ -133,18 +144,15 @@ http.route({
133
144
  geoIpStatus(env.RASTRO_GEOIP_PROVIDER, env.RASTRO_GEOIP_TOKEN) ===
134
145
  "ready"
135
146
  ) {
136
- const events = enrichmentCandidateEvents(payload.events);
137
- if (events) {
147
+ const candidates = enrichmentCandidateEvents(events);
148
+ if (candidates) {
138
149
  let stage: GeoIpFailureStage = "claim";
139
150
  try {
140
- const claim = await ctx.runMutation(
141
- internal.geo.claimEnrichment,
142
- {
143
- siteId: payload.siteId as Id<"sites">,
144
- events,
145
- dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
146
- },
147
- );
151
+ const claim = await ctx.runMutation(internal.geo.claimEnrichment, {
152
+ siteId: payload.siteId as Id<"sites">,
153
+ events: candidates,
154
+ dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
155
+ });
148
156
  if (claim.sessionIds.length > 0) {
149
157
  stage = "resolve";
150
158
  const providerGeo = await resolveGeoIp(
@@ -161,19 +169,11 @@ http.route({
161
169
  context: providerGeo,
162
170
  });
163
171
  } else {
164
- logGeoIpFailure(
165
- requestId,
166
- stage,
167
- env.RASTRO_GEOIP_PROVIDER!,
168
- );
172
+ logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
169
173
  }
170
174
  }
171
175
  } catch {
172
- logGeoIpFailure(
173
- requestId,
174
- stage,
175
- env.RASTRO_GEOIP_PROVIDER!,
176
- );
176
+ logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
177
177
  }
178
178
  }
179
179
  }
@@ -193,9 +193,7 @@ http.route({
193
193
  status === 429
194
194
  ? {
195
195
  ...headers,
196
- "Retry-After": String(
197
- Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000),
198
- ),
196
+ "Retry-After": String(Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000)),
199
197
  }
200
198
  : headers,
201
199
  );
@@ -245,6 +243,24 @@ http.route({
245
243
  }),
246
244
  });
247
245
 
246
+ http.route({
247
+ path: "/vitals.js",
248
+ 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
+ }),
262
+ });
263
+
248
264
  http.route({
249
265
  path: "/health",
250
266
  method: "GET",
@@ -271,14 +287,17 @@ http.route({
271
287
  trustedProxy: trustProxy,
272
288
  proxyRequestTrusted,
273
289
  edgeHeadersAvailable:
274
- proxyRequestTrusted && Object.keys(geoFromHeaders(request)).length > 0,
290
+ proxyRequestTrusted &&
291
+ Object.keys(geoFromHeaders(request)).length > 0,
275
292
  },
276
293
  features: {
277
294
  botDetection: "userAgent",
278
295
  uniqueVisitors:
279
- clientIp.ip && (metadata.userAgent ?? request.headers.get("user-agent"))
296
+ clientIp.ip &&
297
+ (metadata.userAgent ?? request.headers.get("user-agent"))
280
298
  ? "dailyHash"
281
299
  : "session",
300
+ webVitals: "optIn",
282
301
  },
283
302
  },
284
303
  200,
@@ -293,6 +312,7 @@ http.route({
293
312
  function parsePayload(value: unknown): {
294
313
  siteId: string;
295
314
  events: TrackerEvent[];
315
+ sentAt?: number;
296
316
  } {
297
317
  if (
298
318
  !isPlainRecord(value) ||
@@ -301,7 +321,12 @@ function parsePayload(value: unknown): {
301
321
  ) {
302
322
  throw new Error("body must contain siteId and events");
303
323
  }
304
- return { siteId: value.siteId, events: value.events.map(parseEvent) };
324
+ const sentAt = optionalNumber(value.sentAt, "sentAt");
325
+ return {
326
+ siteId: value.siteId,
327
+ events: value.events.map(parseEvent),
328
+ ...(sentAt === undefined ? {} : { sentAt }),
329
+ };
305
330
  }
306
331
 
307
332
  class PayloadTooLargeError extends Error {}
@@ -342,7 +367,8 @@ function parseEvent(value: unknown): TrackerEvent {
342
367
  type !== "custom" &&
343
368
  type !== "conversion" &&
344
369
  type !== "heartbeat" &&
345
- type !== "outbound"
370
+ type !== "outbound" &&
371
+ type !== "vital"
346
372
  ) {
347
373
  throw new Error("event type is invalid");
348
374
  }
@@ -387,6 +413,9 @@ function parseEvent(value: unknown): TrackerEvent {
387
413
  ...(optionalString(value.affiliateSlug, "affiliateSlug") !== undefined
388
414
  ? { affiliateSlug: optionalString(value.affiliateSlug, "affiliateSlug") }
389
415
  : {}),
416
+ ...(optionalNumber(value.value, "value") !== undefined
417
+ ? { value: optionalNumber(value.value, "value") }
418
+ : {}),
390
419
  };
391
420
  }
392
421
 
@@ -480,9 +509,7 @@ function geoFromHeaders(request: Request): {
480
509
  return {
481
510
  ...(country ? { country } : {}),
482
511
  ...(city ? { city } : {}),
483
- ...(validCoordinates(latitude, longitude)
484
- ? { latitude, longitude }
485
- : {}),
512
+ ...(validCoordinates(latitude, longitude) ? { latitude, longitude } : {}),
486
513
  };
487
514
  }
488
515
 
@@ -497,12 +524,14 @@ function validCoordinates(
497
524
  latitude: number | undefined,
498
525
  longitude: number | undefined,
499
526
  ): latitude is number {
500
- return latitude !== undefined &&
527
+ return (
528
+ latitude !== undefined &&
501
529
  longitude !== undefined &&
502
530
  latitude >= -90 &&
503
531
  latitude <= 90 &&
504
532
  longitude >= -180 &&
505
- longitude <= 180;
533
+ longitude <= 180
534
+ );
506
535
  }
507
536
 
508
537
  function firstHeader(request: Request, names: string[]): string | undefined {
@@ -532,13 +561,15 @@ function decodeHeader(value: string | undefined): string | undefined {
532
561
  type GeoIpFailureStage = "claim" | "resolve" | "apply";
533
562
 
534
563
  function logInternalHttpError(requestId: string): void {
535
- console.error(JSON.stringify({
536
- level: "error",
537
- event: "rastro.http.internal_error",
538
- requestId,
539
- route: "/events",
540
- method: "POST",
541
- }));
564
+ console.error(
565
+ JSON.stringify({
566
+ level: "error",
567
+ event: "rastro.http.internal_error",
568
+ requestId,
569
+ route: "/events",
570
+ method: "POST",
571
+ }),
572
+ );
542
573
  }
543
574
 
544
575
  function logGeoIpFailure(
@@ -546,21 +577,26 @@ function logGeoIpFailure(
546
577
  stage: GeoIpFailureStage,
547
578
  provider: "ipinfo" | "ipwhois",
548
579
  ): void {
549
- console.warn(JSON.stringify({
550
- level: "warn",
551
- event: "rastro.geoip.failure",
552
- requestId,
553
- route: "/events",
554
- stage,
555
- provider,
556
- }));
580
+ console.warn(
581
+ JSON.stringify({
582
+ level: "warn",
583
+ event: "rastro.geoip.failure",
584
+ requestId,
585
+ route: "/events",
586
+ stage,
587
+ provider,
588
+ }),
589
+ );
557
590
  }
558
591
 
559
592
  function requestIdHeaders(requestId: string): Record<string, string> {
560
593
  return { "X-Request-ID": requestId };
561
594
  }
562
595
 
563
- function corsHeaders(origin: string, requestId?: string): Record<string, string> {
596
+ function corsHeaders(
597
+ origin: string,
598
+ requestId?: string,
599
+ ): Record<string, string> {
564
600
  return {
565
601
  "Access-Control-Allow-Origin": origin,
566
602
  "Access-Control-Allow-Methods": "POST, OPTIONS",
@@ -585,7 +621,10 @@ let cachedVisitorSecret: string | undefined;
585
621
 
586
622
  /** The per-deployment salt behind anonymous visitor keys, created on first use. */
587
623
  async function visitorSecret(ctx: {
588
- runQuery: (reference: typeof internal.identity.secret, args: Record<string, never>) => Promise<string | null>;
624
+ runQuery: (
625
+ reference: typeof internal.identity.secret,
626
+ args: Record<string, never>,
627
+ ) => Promise<string | null>;
589
628
  runMutation: (
590
629
  reference: typeof internal.identity.ensureSecret,
591
630
  args: { candidate: string },