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

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 (111) hide show
  1. package/README.md +374 -62
  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 +1658 -8
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +228 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +10 -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 +169 -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 +11 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +11 -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/eventStore.d.ts +1 -0
  34. package/dist/component/eventStore.d.ts.map +1 -1
  35. package/dist/component/http.d.ts.map +1 -1
  36. package/dist/component/http.js +85 -36
  37. package/dist/component/http.js.map +1 -1
  38. package/dist/component/identity.d.ts +13 -0
  39. package/dist/component/identity.d.ts.map +1 -0
  40. package/dist/component/identity.js +58 -0
  41. package/dist/component/identity.js.map +1 -0
  42. package/dist/component/ingest.d.ts +1 -0
  43. package/dist/component/ingest.d.ts.map +1 -1
  44. package/dist/component/ingest.js +258 -19
  45. package/dist/component/ingest.js.map +1 -1
  46. package/dist/component/live.d.ts.map +1 -1
  47. package/dist/component/live.js +6 -3
  48. package/dist/component/live.js.map +1 -1
  49. package/dist/component/reports.d.ts +120 -2
  50. package/dist/component/reports.d.ts.map +1 -1
  51. package/dist/component/reports.js +659 -91
  52. package/dist/component/reports.js.map +1 -1
  53. package/dist/component/retention.d.ts +75 -1
  54. package/dist/component/retention.d.ts.map +1 -1
  55. package/dist/component/retention.js +517 -54
  56. package/dist/component/retention.js.map +1 -1
  57. package/dist/component/sanitize.d.ts +4 -1
  58. package/dist/component/sanitize.d.ts.map +1 -1
  59. package/dist/component/sanitize.js +13 -5
  60. package/dist/component/sanitize.js.map +1 -1
  61. package/dist/component/schema.d.ts +224 -7
  62. package/dist/component/schema.js +113 -1
  63. package/dist/component/schema.js.map +1 -1
  64. package/dist/component/sites.d.ts.map +1 -1
  65. package/dist/component/sites.js +5 -1
  66. package/dist/component/sites.js.map +1 -1
  67. package/dist/component/useragent.d.ts +9 -0
  68. package/dist/component/useragent.d.ts.map +1 -0
  69. package/dist/component/useragent.js +152 -0
  70. package/dist/component/useragent.js.map +1 -0
  71. package/dist/component/validators.d.ts +42 -11
  72. package/dist/component/validators.d.ts.map +1 -1
  73. package/dist/component/validators.js +10 -1
  74. package/dist/component/validators.js.map +1 -1
  75. package/dist/component/visitors.d.ts +19 -0
  76. package/dist/component/visitors.d.ts.map +1 -0
  77. package/dist/component/visitors.js +86 -0
  78. package/dist/component/visitors.js.map +1 -0
  79. package/dist/tracker/generated.d.ts +4 -4
  80. package/dist/tracker/generated.d.ts.map +1 -1
  81. package/dist/tracker/generated.js +4 -4
  82. package/dist/tracker/generated.js.map +1 -1
  83. package/dist/tracker/tracker.js +23 -11
  84. package/dist/tracker/tracker.js.map +1 -1
  85. package/dist/tracker.min.js +1 -1
  86. package/docs/benchmarks/2026-08-20-realistic.md +4 -4
  87. package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
  88. package/docs/federation-setup.md +395 -0
  89. package/docs/federation.md +258 -0
  90. package/docs/upgrading.md +130 -0
  91. package/llms.txt +65 -0
  92. package/package.json +41 -11
  93. package/scripts/generate-federation-keys.mjs +20 -0
  94. package/src/component/_generated/api.ts +10 -0
  95. package/src/component/_generated/component.ts +228 -1
  96. package/src/component/cardinality.ts +116 -0
  97. package/src/component/constants.ts +11 -0
  98. package/src/component/coverage.ts +25 -0
  99. package/src/component/errors.ts +2 -1
  100. package/src/component/http.ts +129 -38
  101. package/src/component/identity.ts +74 -0
  102. package/src/component/ingest.ts +414 -13
  103. package/src/component/live.ts +8 -3
  104. package/src/component/reports.ts +910 -98
  105. package/src/component/retention.ts +624 -88
  106. package/src/component/sanitize.ts +19 -5
  107. package/src/component/schema.ts +125 -0
  108. package/src/component/sites.ts +5 -1
  109. package/src/component/useragent.ts +171 -0
  110. package/src/component/validators.ts +11 -0
  111. package/src/component/visitors.ts +105 -0
@@ -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,
@@ -19,6 +20,8 @@ import {
19
20
  } from "./geo.js";
20
21
  import { isPlainRecord } from "./guards.js";
21
22
  import { sanitizeEvent } from "./sanitize.js";
23
+ import { deriveVisitorKey, randomSecret } from "./identity.js";
24
+ import { classifyClient, isKnownBot } from "./useragent.js";
22
25
  import type { TrackerEvent } from "./validators.js";
23
26
  import {
24
27
  TRACKER_GZIP_BASE64,
@@ -35,9 +38,16 @@ http.route({
35
38
  path: "/events",
36
39
  method: "POST",
37
40
  handler: httpAction(async (ctx, request) => {
41
+ const requestId = crypto.randomUUID();
38
42
  const origin = request.headers.get("origin");
39
- if (!origin) return json({ error: { code: "ORIGIN_REQUIRED" } }, 400);
40
- const headers = corsHeaders(origin);
43
+ if (!origin) {
44
+ return json(
45
+ { error: { code: "ORIGIN_REQUIRED" } },
46
+ 400,
47
+ requestIdHeaders(requestId),
48
+ );
49
+ }
50
+ const headers = corsHeaders(origin, requestId);
41
51
  const contentLength = Number(request.headers.get("content-length"));
42
52
  if (Number.isFinite(contentLength) && contentLength > MAX_BATCH_BYTES) {
43
53
  return json(
@@ -80,9 +90,34 @@ http.route({
80
90
  const edgeGeo = proxyRequestTrusted ? geoFromHeaders(request) : {};
81
91
  const userAgent =
82
92
  metadata.userAgent ?? request.headers.get("user-agent") ?? "";
93
+ // Self-declared bots never reach a transaction; the response keeps the
94
+ // counter shape the tracker expects and names the reason in a header.
95
+ if (isKnownBot(userAgent)) {
96
+ return json(
97
+ { accepted: 0, duplicates: 0, rejected: payload.events.length },
98
+ 200,
99
+ { ...headers, "X-Rastro-Rejected": "bot" },
100
+ );
101
+ }
102
+ // Anonymous batches (visitorId === sessionId) get a salted daily key so
103
+ // repeat visits count once; the IP itself stops here.
104
+ const anonymous = payload.events.some(
105
+ (event) => event.visitorId === event.sessionId,
106
+ );
107
+ const visitorKey =
108
+ anonymous && clientIp.ip && userAgent
109
+ ? await deriveVisitorKey({
110
+ secret: await visitorSecret(ctx),
111
+ now: Date.now(),
112
+ siteId: payload.siteId,
113
+ ip: clientIp.ip,
114
+ userAgent,
115
+ })
116
+ : undefined;
83
117
  const context = {
84
118
  ...edgeGeo,
85
- ...classifyUserAgent(userAgent),
119
+ ...classifyClient(userAgent, request.headers),
120
+ ...(visitorKey ? { visitorKey } : {}),
86
121
  };
87
122
 
88
123
  try {
@@ -100,6 +135,7 @@ http.route({
100
135
  ) {
101
136
  const events = enrichmentCandidateEvents(payload.events);
102
137
  if (events) {
138
+ let stage: GeoIpFailureStage = "claim";
103
139
  try {
104
140
  const claim = await ctx.runMutation(
105
141
  internal.geo.claimEnrichment,
@@ -110,22 +146,34 @@ http.route({
110
146
  },
111
147
  );
112
148
  if (claim.sessionIds.length > 0) {
149
+ stage = "resolve";
113
150
  const providerGeo = await resolveGeoIp(
114
151
  clientIp.ip,
115
152
  env.RASTRO_GEOIP_PROVIDER!,
116
153
  env.RASTRO_GEOIP_TOKEN,
117
154
  );
118
155
  if (providerGeo) {
156
+ stage = "apply";
119
157
  await ctx.runMutation(internal.geo.applyEnrichment, {
120
158
  siteId: payload.siteId as Id<"sites">,
121
159
  sessionIds: claim.sessionIds,
122
160
  events: claim.events,
123
161
  context: providerGeo,
124
162
  });
163
+ } else {
164
+ logGeoIpFailure(
165
+ requestId,
166
+ stage,
167
+ env.RASTRO_GEOIP_PROVIDER!,
168
+ );
125
169
  }
126
170
  }
127
171
  } catch {
128
- // Analytics ingestion remains available if optional enrichment fails.
172
+ logGeoIpFailure(
173
+ requestId,
174
+ stage,
175
+ env.RASTRO_GEOIP_PROVIDER!,
176
+ );
129
177
  }
130
178
  }
131
179
  }
@@ -139,9 +187,25 @@ http.route({
139
187
  : data.code === "RATE_LIMITED"
140
188
  ? 429
141
189
  : 400;
142
- return json({ error: data }, status, headers);
190
+ return json(
191
+ { error: data },
192
+ status,
193
+ status === 429
194
+ ? {
195
+ ...headers,
196
+ "Retry-After": String(
197
+ Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000),
198
+ ),
199
+ }
200
+ : headers,
201
+ );
143
202
  }
144
- return json({ error: { code: "INVALID_REQUEST" } }, 400, headers);
203
+ logInternalHttpError(requestId);
204
+ return json(
205
+ { error: { code: "INTERNAL_ERROR", requestId } },
206
+ 500,
207
+ headers,
208
+ );
145
209
  }
146
210
  }),
147
211
  });
@@ -209,6 +273,13 @@ http.route({
209
273
  edgeHeadersAvailable:
210
274
  proxyRequestTrusted && Object.keys(geoFromHeaders(request)).length > 0,
211
275
  },
276
+ features: {
277
+ botDetection: "userAgent",
278
+ uniqueVisitors:
279
+ clientIp.ip && (metadata.userAgent ?? request.headers.get("user-agent"))
280
+ ? "dailyHash"
281
+ : "session",
282
+ },
212
283
  },
213
284
  200,
214
285
  {
@@ -458,45 +529,45 @@ function decodeHeader(value: string | undefined): string | undefined {
458
529
  }
459
530
  }
460
531
 
461
- function classifyUserAgent(userAgent: string): {
462
- browser: string;
463
- os: string;
464
- device: string;
465
- } {
466
- const browser = /Edg\//.test(userAgent)
467
- ? "Edge"
468
- : /Firefox\//.test(userAgent)
469
- ? "Firefox"
470
- : /Chrome\//.test(userAgent)
471
- ? "Chrome"
472
- : /Safari\//.test(userAgent)
473
- ? "Safari"
474
- : "Other";
475
- const os = /Android/.test(userAgent)
476
- ? "Android"
477
- : /iPhone|iPad/.test(userAgent)
478
- ? "iOS"
479
- : /Windows/.test(userAgent)
480
- ? "Windows"
481
- : /Mac OS X/.test(userAgent)
482
- ? "macOS"
483
- : /Linux/.test(userAgent)
484
- ? "Linux"
485
- : "Other";
486
- const device = /iPad|Tablet/.test(userAgent)
487
- ? "tablet"
488
- : /Mobile|Android|iPhone/.test(userAgent)
489
- ? "mobile"
490
- : "desktop";
491
- return { browser, os, device };
532
+ type GeoIpFailureStage = "claim" | "resolve" | "apply";
533
+
534
+ 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
+ }));
542
+ }
543
+
544
+ function logGeoIpFailure(
545
+ requestId: string,
546
+ stage: GeoIpFailureStage,
547
+ provider: "ipinfo" | "ipwhois",
548
+ ): void {
549
+ console.warn(JSON.stringify({
550
+ level: "warn",
551
+ event: "rastro.geoip.failure",
552
+ requestId,
553
+ route: "/events",
554
+ stage,
555
+ provider,
556
+ }));
557
+ }
558
+
559
+ function requestIdHeaders(requestId: string): Record<string, string> {
560
+ return { "X-Request-ID": requestId };
492
561
  }
493
562
 
494
- function corsHeaders(origin: string): HeadersInit {
563
+ function corsHeaders(origin: string, requestId?: string): Record<string, string> {
495
564
  return {
496
565
  "Access-Control-Allow-Origin": origin,
497
566
  "Access-Control-Allow-Methods": "POST, OPTIONS",
498
567
  "Access-Control-Allow-Headers": "Content-Type",
568
+ "Access-Control-Expose-Headers": "X-Request-ID, Retry-After",
499
569
  "Access-Control-Max-Age": "86400",
570
+ ...(requestId ? requestIdHeaders(requestId) : {}),
500
571
  Vary: "Origin",
501
572
  };
502
573
  }
@@ -509,3 +580,23 @@ function json(value: unknown, status: number, headers?: HeadersInit): Response {
509
580
  }
510
581
 
511
582
  export default http;
583
+
584
+ let cachedVisitorSecret: string | undefined;
585
+
586
+ /** The per-deployment salt behind anonymous visitor keys, created on first use. */
587
+ async function visitorSecret(ctx: {
588
+ runQuery: (reference: typeof internal.identity.secret, args: Record<string, never>) => Promise<string | null>;
589
+ runMutation: (
590
+ reference: typeof internal.identity.ensureSecret,
591
+ args: { candidate: string },
592
+ ) => Promise<string>;
593
+ }): Promise<string> {
594
+ if (cachedVisitorSecret) return cachedVisitorSecret;
595
+ const stored = await ctx.runQuery(internal.identity.secret, {});
596
+ cachedVisitorSecret =
597
+ stored ??
598
+ (await ctx.runMutation(internal.identity.ensureSecret, {
599
+ candidate: randomSecret(),
600
+ }));
601
+ return cachedVisitorSecret;
602
+ }
@@ -0,0 +1,74 @@
1
+ import { v } from "convex/values";
2
+ import { DAY_MS } from "./constants.js";
3
+ import { internalMutation, internalQuery } from "./_generated/server.js";
4
+
5
+ /**
6
+ * Anonymous visitors (no host-supplied id) are counted through a salted hash of
7
+ * the request IP and User-Agent that rotates every UTC day. The hash is derived
8
+ * in the HTTP action, so the raw IP never reaches a mutation or a table, and the
9
+ * salt is a per-deployment secret, so nobody holding a key can walk the IPv4
10
+ * space back to an address. Same device and network on the same day count once;
11
+ * the next day they are a new visitor by design.
12
+ */
13
+ const KEY_HEX_LENGTH = 24;
14
+ const SECRET_PATTERN = /^[0-9a-f]{64}$/;
15
+
16
+ export const secret = internalQuery({
17
+ args: {},
18
+ returns: v.union(v.string(), v.null()),
19
+ handler: async (ctx) => {
20
+ const row = await ctx.db
21
+ .query("visitorSecrets")
22
+ .withIndex("by_key", (range) => range.eq("key", "current"))
23
+ .unique();
24
+ return row?.value ?? null;
25
+ },
26
+ });
27
+
28
+ export const ensureSecret = internalMutation({
29
+ args: { candidate: v.string() },
30
+ returns: v.string(),
31
+ handler: async (ctx, args) => {
32
+ const existing = await ctx.db
33
+ .query("visitorSecrets")
34
+ .withIndex("by_key", (range) => range.eq("key", "current"))
35
+ .unique();
36
+ if (existing) return existing.value;
37
+ if (!SECRET_PATTERN.test(args.candidate)) {
38
+ throw new Error("visitor secret candidate must be 64 hex characters");
39
+ }
40
+ await ctx.db.insert("visitorSecrets", {
41
+ key: "current",
42
+ value: args.candidate,
43
+ createdAt: Date.now(),
44
+ });
45
+ return args.candidate;
46
+ },
47
+ });
48
+
49
+ export function randomSecret(): string {
50
+ return hex(crypto.getRandomValues(new Uint8Array(32)));
51
+ }
52
+
53
+ export async function deriveVisitorKey(input: {
54
+ secret: string;
55
+ now: number;
56
+ siteId: string;
57
+ ip: string;
58
+ userAgent: string;
59
+ }): Promise<string> {
60
+ const dayStart = Math.floor(input.now / DAY_MS) * DAY_MS;
61
+ const material = new TextEncoder().encode(
62
+ [input.secret, dayStart, input.siteId, input.ip, input.userAgent].join(" "),
63
+ );
64
+ const digest = new Uint8Array(
65
+ await crypto.subtle.digest("SHA-256", material),
66
+ );
67
+ return hex(digest).slice(0, KEY_HEX_LENGTH);
68
+ }
69
+
70
+ function hex(bytes: Uint8Array): string {
71
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
72
+ "",
73
+ );
74
+ }