@iann29/rastro 0.1.0-alpha.3 → 0.1.0-alpha.4

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 (86) hide show
  1. package/README.md +264 -51
  2. package/agent/integration.md +651 -0
  3. package/agent/manifest.json +183 -0
  4. package/agent/manifest.schema.json +405 -0
  5. package/dist/client/federation.d.ts +205 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +179 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +1600 -8
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +210 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +4 -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 +151 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/cardinality.d.ts +12 -0
  19. package/dist/component/cardinality.d.ts.map +1 -0
  20. package/dist/component/cardinality.js +94 -0
  21. package/dist/component/cardinality.js.map +1 -0
  22. package/dist/component/constants.d.ts +10 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +10 -0
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +18 -0
  27. package/dist/component/coverage.d.ts.map +1 -0
  28. package/dist/component/coverage.js +19 -0
  29. package/dist/component/coverage.js.map +1 -0
  30. package/dist/component/errors.d.ts +1 -1
  31. package/dist/component/errors.d.ts.map +1 -1
  32. package/dist/component/errors.js.map +1 -1
  33. package/dist/component/http.d.ts.map +1 -1
  34. package/dist/component/http.js +46 -8
  35. package/dist/component/http.js.map +1 -1
  36. package/dist/component/ingest.d.ts.map +1 -1
  37. package/dist/component/ingest.js +238 -18
  38. package/dist/component/ingest.js.map +1 -1
  39. package/dist/component/live.d.ts.map +1 -1
  40. package/dist/component/live.js +6 -3
  41. package/dist/component/live.js.map +1 -1
  42. package/dist/component/reports.d.ts +111 -2
  43. package/dist/component/reports.d.ts.map +1 -1
  44. package/dist/component/reports.js +639 -78
  45. package/dist/component/reports.js.map +1 -1
  46. package/dist/component/retention.d.ts +75 -1
  47. package/dist/component/retention.d.ts.map +1 -1
  48. package/dist/component/retention.js +517 -54
  49. package/dist/component/retention.js.map +1 -1
  50. package/dist/component/sanitize.d.ts +4 -1
  51. package/dist/component/sanitize.d.ts.map +1 -1
  52. package/dist/component/sanitize.js +9 -4
  53. package/dist/component/sanitize.js.map +1 -1
  54. package/dist/component/schema.d.ts +187 -1
  55. package/dist/component/schema.js +100 -1
  56. package/dist/component/schema.js.map +1 -1
  57. package/dist/component/sites.d.ts.map +1 -1
  58. package/dist/component/sites.js +5 -1
  59. package/dist/component/sites.js.map +1 -1
  60. package/dist/component/validators.d.ts +26 -1
  61. package/dist/component/validators.d.ts.map +1 -1
  62. package/dist/component/validators.js +7 -0
  63. package/dist/component/validators.js.map +1 -1
  64. package/docs/benchmarks/2026-08-20-realistic.md +4 -4
  65. package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
  66. package/docs/federation-setup.md +395 -0
  67. package/docs/federation.md +258 -0
  68. package/docs/upgrading.md +130 -0
  69. package/llms.txt +65 -0
  70. package/package.json +32 -6
  71. package/scripts/generate-federation-keys.mjs +20 -0
  72. package/src/component/_generated/api.ts +4 -0
  73. package/src/component/_generated/component.ts +211 -1
  74. package/src/component/cardinality.ts +116 -0
  75. package/src/component/constants.ts +10 -0
  76. package/src/component/coverage.ts +25 -0
  77. package/src/component/errors.ts +2 -1
  78. package/src/component/http.ts +76 -6
  79. package/src/component/ingest.ts +387 -12
  80. package/src/component/live.ts +8 -3
  81. package/src/component/reports.ts +891 -86
  82. package/src/component/retention.ts +624 -88
  83. package/src/component/sanitize.ts +15 -4
  84. package/src/component/schema.ts +110 -0
  85. package/src/component/sites.ts +5 -1
  86. package/src/component/validators.ts +8 -0
@@ -0,0 +1,116 @@
1
+ const SKETCH_VERSION = 1 as const;
2
+ const DEFAULT_PRECISION = 6;
3
+
4
+ export type VisitorSketch = {
5
+ version: typeof SKETCH_VERSION;
6
+ precision: number;
7
+ registers: ArrayBuffer;
8
+ };
9
+
10
+ export function createVisitorSketch(
11
+ precision = DEFAULT_PRECISION,
12
+ ): VisitorSketch {
13
+ if (!Number.isSafeInteger(precision) || precision < 4 || precision > 10) {
14
+ throw new Error("visitor sketch precision must be between 4 and 10");
15
+ }
16
+ return {
17
+ version: SKETCH_VERSION,
18
+ precision,
19
+ registers: new Uint8Array(1 << precision).buffer,
20
+ };
21
+ }
22
+
23
+ export function addVisitor(
24
+ sketch: VisitorSketch,
25
+ visitorId: string,
26
+ ): VisitorSketch {
27
+ const registers = checkedRegisters(sketch).slice();
28
+ const hash = fnv1a64(visitorId);
29
+ const remainingBits = 64 - sketch.precision;
30
+ const index = Number(hash >> BigInt(remainingBits));
31
+ const remaining = hash & ((1n << BigInt(remainingBits)) - 1n);
32
+ const rank = leadingZeroRank(remaining, remainingBits);
33
+ registers[index] = Math.max(registers[index] ?? 0, rank);
34
+ return { ...sketch, registers: registers.buffer };
35
+ }
36
+
37
+ export function unionVisitorSketches(
38
+ left: VisitorSketch,
39
+ right: VisitorSketch,
40
+ ): VisitorSketch {
41
+ const leftRegisters = checkedRegisters(left);
42
+ const rightRegisters = checkedRegisters(right);
43
+ if (
44
+ left.version !== right.version ||
45
+ left.precision !== right.precision ||
46
+ leftRegisters.length !== rightRegisters.length
47
+ ) {
48
+ throw new Error("visitor sketches are incompatible");
49
+ }
50
+ const registers = leftRegisters.slice();
51
+ for (let index = 0; index < registers.length; index += 1) {
52
+ registers[index] = Math.max(
53
+ registers[index] ?? 0,
54
+ rightRegisters[index] ?? 0,
55
+ );
56
+ }
57
+ return { ...left, registers: registers.buffer };
58
+ }
59
+
60
+ export function estimateVisitors(sketch: VisitorSketch): number {
61
+ const registers = checkedRegisters(sketch);
62
+ const count = registers.length;
63
+ const alpha = count === 16
64
+ ? 0.673
65
+ : count === 32
66
+ ? 0.697
67
+ : count === 64
68
+ ? 0.709
69
+ : 0.7213 / (1 + 1.079 / count);
70
+ let denominator = 0;
71
+ let empty = 0;
72
+ for (const register of registers) {
73
+ denominator += 2 ** -register;
74
+ if (register === 0) empty += 1;
75
+ }
76
+ let estimate = (alpha * count * count) / denominator;
77
+ if (estimate <= 2.5 * count && empty > 0) {
78
+ estimate = count * Math.log(count / empty);
79
+ }
80
+ return Math.max(0, Math.round(estimate));
81
+ }
82
+
83
+ function checkedRegisters(sketch: VisitorSketch): Uint8Array<ArrayBuffer> {
84
+ if (sketch.version !== SKETCH_VERSION) {
85
+ throw new Error("unsupported visitor sketch version");
86
+ }
87
+ const registers = new Uint8Array(sketch.registers);
88
+ if (registers.length !== 1 << sketch.precision) {
89
+ throw new Error("visitor sketch has invalid register storage");
90
+ }
91
+ return registers;
92
+ }
93
+
94
+ function leadingZeroRank(value: bigint, bits: number): number {
95
+ if (value === 0n) return bits + 1;
96
+ let rank = 1;
97
+ for (let bit = bits - 1; bit >= 0; bit -= 1) {
98
+ if ((value & (1n << BigInt(bit))) !== 0n) break;
99
+ rank += 1;
100
+ }
101
+ return rank;
102
+ }
103
+
104
+ function fnv1a64(value: string): bigint {
105
+ let hash = 0xcbf29ce484222325n;
106
+ for (const byte of new TextEncoder().encode(value)) {
107
+ hash ^= BigInt(byte);
108
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
109
+ }
110
+ hash ^= hash >> 33n;
111
+ hash = BigInt.asUintN(64, hash * 0xff51afd7ed558ccdn);
112
+ hash ^= hash >> 33n;
113
+ hash = BigInt.asUintN(64, hash * 0xc4ceb9fe1a85ec53n);
114
+ hash ^= hash >> 33n;
115
+ return hash;
116
+ }
@@ -28,6 +28,16 @@ export const JOURNEY_READ_BYTE_RESERVE = 4 * 1024 * 1024;
28
28
  export const JOURNEY_READ_DOCUMENT_RESERVE = 1_000;
29
29
  export const MAX_RETENTION_BATCH = 500;
30
30
  export const MAX_OVERVIEW_AGGREGATE_ROWS = 13_000;
31
+ export const MAX_REPORT_BUCKETS = 5_000;
32
+ export const MAX_FEATURE_ROLLUP_DAYS = 90;
33
+ export const MAX_FEATURE_ROLLUP_ROWS = 5_000;
34
+ export const FEATURE_REPORT_BYTE_RESERVE = 2 * 1024 * 1024;
35
+ export const FEATURE_REPORT_DOCUMENT_RESERVE = 500;
36
+ export const REPORT_ROLLUP_SHARDS = 2;
37
+ export const ROLLUP_SAFETY_DELAY_MS = 5 * 60_000;
38
+ export const RETENTION_LEASE_MS = 60_000;
31
39
 
32
40
  export const DAY_MS = 86_400_000;
33
41
  export const HOUR_MS = 3_600_000;
42
+ export const MINUTE_MS = 60_000;
43
+ export const RETENTION_POLICY_INTERVAL_MS = DAY_MS;
@@ -0,0 +1,25 @@
1
+ import type { Id } from "./_generated/dataModel.js";
2
+ import type { MutationCtx } from "./_generated/server.js";
3
+ import { ROLLUP_SAFETY_DELAY_MS } from "./constants.js";
4
+
5
+ export async function ensureAnalyticsControl(
6
+ ctx: MutationCtx,
7
+ siteId: Id<"sites">,
8
+ now: number,
9
+ retained: Array<{ dataset: string; before: number }> = [],
10
+ ) {
11
+ const existing = await ctx.db
12
+ .query("analyticsCoverage")
13
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
14
+ .unique();
15
+ if (existing) return existing;
16
+ const fields = {
17
+ siteId,
18
+ generation: 1 as const,
19
+ rollupCompleteFrom: now + ROLLUP_SAFETY_DELAY_MS,
20
+ retained,
21
+ updatedAt: now,
22
+ };
23
+ const _id = await ctx.db.insert("analyticsCoverage", fields);
24
+ return { ...fields, _id, _creationTime: now };
25
+ }
@@ -9,7 +9,8 @@ export type RastroErrorCode =
9
9
  | "LIMIT_EXCEEDED"
10
10
  | "CONFLICT"
11
11
  | "CURRENCY_MISMATCH"
12
- | "RATE_LIMITED";
12
+ | "RATE_LIMITED"
13
+ | "REPORT_INCOMPLETE";
13
14
 
14
15
  export function fail(
15
16
  code: RastroErrorCode,
@@ -7,6 +7,7 @@ import {
7
7
  MAX_BATCH_BYTES,
8
8
  MAX_BATCH_EVENT_GROUPS,
9
9
  MAX_BATCH_EVENTS,
10
+ RATE_LIMIT_WINDOW_MS,
10
11
  } from "./constants.js";
11
12
  import {
12
13
  classifyIp,
@@ -35,9 +36,16 @@ http.route({
35
36
  path: "/events",
36
37
  method: "POST",
37
38
  handler: httpAction(async (ctx, request) => {
39
+ const requestId = crypto.randomUUID();
38
40
  const origin = request.headers.get("origin");
39
- if (!origin) return json({ error: { code: "ORIGIN_REQUIRED" } }, 400);
40
- const headers = corsHeaders(origin);
41
+ if (!origin) {
42
+ return json(
43
+ { error: { code: "ORIGIN_REQUIRED" } },
44
+ 400,
45
+ requestIdHeaders(requestId),
46
+ );
47
+ }
48
+ const headers = corsHeaders(origin, requestId);
41
49
  const contentLength = Number(request.headers.get("content-length"));
42
50
  if (Number.isFinite(contentLength) && contentLength > MAX_BATCH_BYTES) {
43
51
  return json(
@@ -100,6 +108,7 @@ http.route({
100
108
  ) {
101
109
  const events = enrichmentCandidateEvents(payload.events);
102
110
  if (events) {
111
+ let stage: GeoIpFailureStage = "claim";
103
112
  try {
104
113
  const claim = await ctx.runMutation(
105
114
  internal.geo.claimEnrichment,
@@ -110,22 +119,34 @@ http.route({
110
119
  },
111
120
  );
112
121
  if (claim.sessionIds.length > 0) {
122
+ stage = "resolve";
113
123
  const providerGeo = await resolveGeoIp(
114
124
  clientIp.ip,
115
125
  env.RASTRO_GEOIP_PROVIDER!,
116
126
  env.RASTRO_GEOIP_TOKEN,
117
127
  );
118
128
  if (providerGeo) {
129
+ stage = "apply";
119
130
  await ctx.runMutation(internal.geo.applyEnrichment, {
120
131
  siteId: payload.siteId as Id<"sites">,
121
132
  sessionIds: claim.sessionIds,
122
133
  events: claim.events,
123
134
  context: providerGeo,
124
135
  });
136
+ } else {
137
+ logGeoIpFailure(
138
+ requestId,
139
+ stage,
140
+ env.RASTRO_GEOIP_PROVIDER!,
141
+ );
125
142
  }
126
143
  }
127
144
  } catch {
128
- // Analytics ingestion remains available if optional enrichment fails.
145
+ logGeoIpFailure(
146
+ requestId,
147
+ stage,
148
+ env.RASTRO_GEOIP_PROVIDER!,
149
+ );
129
150
  }
130
151
  }
131
152
  }
@@ -139,9 +160,25 @@ http.route({
139
160
  : data.code === "RATE_LIMITED"
140
161
  ? 429
141
162
  : 400;
142
- return json({ error: data }, status, headers);
163
+ return json(
164
+ { error: data },
165
+ status,
166
+ status === 429
167
+ ? {
168
+ ...headers,
169
+ "Retry-After": String(
170
+ Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000),
171
+ ),
172
+ }
173
+ : headers,
174
+ );
143
175
  }
144
- return json({ error: { code: "INVALID_REQUEST" } }, 400, headers);
176
+ logInternalHttpError(requestId);
177
+ return json(
178
+ { error: { code: "INTERNAL_ERROR", requestId } },
179
+ 500,
180
+ headers,
181
+ );
145
182
  }
146
183
  }),
147
184
  });
@@ -491,12 +528,45 @@ function classifyUserAgent(userAgent: string): {
491
528
  return { browser, os, device };
492
529
  }
493
530
 
494
- function corsHeaders(origin: string): HeadersInit {
531
+ type GeoIpFailureStage = "claim" | "resolve" | "apply";
532
+
533
+ function logInternalHttpError(requestId: string): void {
534
+ console.error(JSON.stringify({
535
+ level: "error",
536
+ event: "rastro.http.internal_error",
537
+ requestId,
538
+ route: "/events",
539
+ method: "POST",
540
+ }));
541
+ }
542
+
543
+ function logGeoIpFailure(
544
+ requestId: string,
545
+ stage: GeoIpFailureStage,
546
+ provider: "ipinfo" | "ipwhois",
547
+ ): void {
548
+ console.warn(JSON.stringify({
549
+ level: "warn",
550
+ event: "rastro.geoip.failure",
551
+ requestId,
552
+ route: "/events",
553
+ stage,
554
+ provider,
555
+ }));
556
+ }
557
+
558
+ function requestIdHeaders(requestId: string): Record<string, string> {
559
+ return { "X-Request-ID": requestId };
560
+ }
561
+
562
+ function corsHeaders(origin: string, requestId?: string): Record<string, string> {
495
563
  return {
496
564
  "Access-Control-Allow-Origin": origin,
497
565
  "Access-Control-Allow-Methods": "POST, OPTIONS",
498
566
  "Access-Control-Allow-Headers": "Content-Type",
567
+ "Access-Control-Expose-Headers": "X-Request-ID, Retry-After",
499
568
  "Access-Control-Max-Age": "86400",
569
+ ...(requestId ? requestIdHeaders(requestId) : {}),
500
570
  Vary: "Origin",
501
571
  };
502
572
  }