@iann29/rastro 0.1.0-alpha.2 → 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 (124) hide show
  1. package/README.md +325 -63
  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 +9 -5
  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 +161 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/cardinality.d.ts +12 -0
  22. package/dist/component/cardinality.d.ts.map +1 -0
  23. package/dist/component/cardinality.js +94 -0
  24. package/dist/component/cardinality.js.map +1 -0
  25. package/dist/component/constants.d.ts +10 -0
  26. package/dist/component/constants.d.ts.map +1 -1
  27. package/dist/component/constants.js +10 -0
  28. package/dist/component/constants.js.map +1 -1
  29. package/dist/component/convex.config.d.ts +7 -2
  30. package/dist/component/convex.config.d.ts.map +1 -1
  31. package/dist/component/convex.config.js +9 -4
  32. package/dist/component/convex.config.js.map +1 -1
  33. package/dist/component/coverage.d.ts +18 -0
  34. package/dist/component/coverage.d.ts.map +1 -0
  35. package/dist/component/coverage.js +19 -0
  36. package/dist/component/coverage.js.map +1 -0
  37. package/dist/component/diagnostics.d.ts +9 -0
  38. package/dist/component/diagnostics.d.ts.map +1 -0
  39. package/dist/component/diagnostics.js +47 -0
  40. package/dist/component/diagnostics.js.map +1 -0
  41. package/dist/component/errors.d.ts +1 -1
  42. package/dist/component/errors.d.ts.map +1 -1
  43. package/dist/component/errors.js.map +1 -1
  44. package/dist/component/eventStore.d.ts +11 -9
  45. package/dist/component/eventStore.d.ts.map +1 -1
  46. package/dist/component/eventStore.js +55 -227
  47. package/dist/component/eventStore.js.map +1 -1
  48. package/dist/component/geo.d.ts +71 -0
  49. package/dist/component/geo.d.ts.map +1 -0
  50. package/dist/component/geo.js +610 -0
  51. package/dist/component/geo.js.map +1 -0
  52. package/dist/component/http.d.ts.map +1 -1
  53. package/dist/component/http.js +168 -17
  54. package/dist/component/http.js.map +1 -1
  55. package/dist/component/ingest.d.ts.map +1 -1
  56. package/dist/component/ingest.js +272 -32
  57. package/dist/component/ingest.js.map +1 -1
  58. package/dist/component/live.d.ts.map +1 -1
  59. package/dist/component/live.js +6 -3
  60. package/dist/component/live.js.map +1 -1
  61. package/dist/component/reports.d.ts +117 -8
  62. package/dist/component/reports.d.ts.map +1 -1
  63. package/dist/component/reports.js +641 -79
  64. package/dist/component/reports.js.map +1 -1
  65. package/dist/component/retention.d.ts +75 -1
  66. package/dist/component/retention.d.ts.map +1 -1
  67. package/dist/component/retention.js +517 -54
  68. package/dist/component/retention.js.map +1 -1
  69. package/dist/component/sanitize.d.ts +4 -1
  70. package/dist/component/sanitize.d.ts.map +1 -1
  71. package/dist/component/sanitize.js +9 -4
  72. package/dist/component/sanitize.js.map +1 -1
  73. package/dist/component/schema.d.ts +202 -53
  74. package/dist/component/schema.js +113 -16
  75. package/dist/component/schema.js.map +1 -1
  76. package/dist/component/sites.d.ts.map +1 -1
  77. package/dist/component/sites.js +5 -1
  78. package/dist/component/sites.js.map +1 -1
  79. package/dist/component/validators.d.ts +29 -51
  80. package/dist/component/validators.d.ts.map +1 -1
  81. package/dist/component/validators.js +11 -12
  82. package/dist/component/validators.js.map +1 -1
  83. package/dist/tracker/generated.d.ts +4 -4
  84. package/dist/tracker/generated.d.ts.map +1 -1
  85. package/dist/tracker/generated.js +4 -4
  86. package/dist/tracker/generated.js.map +1 -1
  87. package/dist/tracker/tracker.js +7 -6
  88. package/dist/tracker/tracker.js.map +1 -1
  89. package/dist/tracker.min.js +1 -1
  90. package/docs/benchmarks/2026-08-20-realistic.md +5 -5
  91. package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
  92. package/docs/federation-setup.md +395 -0
  93. package/docs/federation.md +258 -0
  94. package/docs/upgrading.md +130 -0
  95. package/llms.txt +65 -0
  96. package/package.json +29 -7
  97. package/scripts/benchmark-ingest.mjs +81 -32
  98. package/scripts/generate-federation-keys.mjs +20 -0
  99. package/src/component/_generated/api.ts +9 -5
  100. package/src/component/_generated/component.ts +224 -1
  101. package/src/component/_generated/server.ts +4 -0
  102. package/src/component/cardinality.ts +116 -0
  103. package/src/component/constants.ts +10 -0
  104. package/src/component/convex.config.ts +11 -5
  105. package/src/component/coverage.ts +25 -0
  106. package/src/component/diagnostics.ts +64 -0
  107. package/src/component/errors.ts +2 -1
  108. package/src/component/eventStore.ts +78 -280
  109. package/src/component/geo.ts +779 -0
  110. package/src/component/http.ts +259 -22
  111. package/src/component/ingest.ts +436 -27
  112. package/src/component/live.ts +8 -3
  113. package/src/component/reports.ts +895 -87
  114. package/src/component/retention.ts +624 -88
  115. package/src/component/sanitize.ts +15 -4
  116. package/src/component/schema.ts +125 -16
  117. package/src/component/sites.ts +5 -1
  118. package/src/component/validators.ts +12 -13
  119. package/src/test.ts +0 -2
  120. package/dist/component/migrations.d.ts +0 -17
  121. package/dist/component/migrations.d.ts.map +0 -1
  122. package/dist/component/migrations.js +0 -44
  123. package/dist/component/migrations.js.map +0 -1
  124. package/src/component/migrations.ts +0 -52
@@ -1,19 +1,33 @@
1
1
  import { httpRouter } from "convex/server";
2
2
  import { ConvexError } from "convex/values";
3
3
  import type { Id } from "./_generated/dataModel.js";
4
- import { api } from "./_generated/api.js";
5
- import { httpAction } from "./_generated/server.js";
6
- import { MAX_BATCH_BYTES } from "./constants.js";
4
+ import { api, internal } from "./_generated/api.js";
5
+ import { env, httpAction } from "./_generated/server.js";
6
+ import {
7
+ MAX_BATCH_BYTES,
8
+ MAX_BATCH_EVENT_GROUPS,
9
+ MAX_BATCH_EVENTS,
10
+ RATE_LIMIT_WINDOW_MS,
11
+ } from "./constants.js";
12
+ import {
13
+ classifyIp,
14
+ geoIpDailyLimit,
15
+ geoIpStatus,
16
+ isTrustedProxyRequest,
17
+ readRequestMetadata,
18
+ resolveClientIp,
19
+ resolveGeoIp,
20
+ } from "./geo.js";
7
21
  import { isPlainRecord } from "./guards.js";
22
+ import { sanitizeEvent } from "./sanitize.js";
8
23
  import type { TrackerEvent } from "./validators.js";
9
24
  import {
10
25
  TRACKER_GZIP_BASE64,
11
26
  TRACKER_GZIP_BYTES,
12
27
  } from "../tracker/generated.js";
13
28
 
14
- const TRACKER_GZIP = Uint8Array.from(
15
- atob(TRACKER_GZIP_BASE64),
16
- (character) => character.charCodeAt(0),
29
+ const TRACKER_GZIP = Uint8Array.from(atob(TRACKER_GZIP_BASE64), (character) =>
30
+ character.charCodeAt(0),
17
31
  );
18
32
 
19
33
  const http = httpRouter();
@@ -22,9 +36,16 @@ http.route({
22
36
  path: "/events",
23
37
  method: "POST",
24
38
  handler: httpAction(async (ctx, request) => {
39
+ const requestId = crypto.randomUUID();
25
40
  const origin = request.headers.get("origin");
26
- if (!origin) return json({ error: { code: "ORIGIN_REQUIRED" } }, 400);
27
- 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);
28
49
  const contentLength = Number(request.headers.get("content-length"));
29
50
  if (Number.isFinite(contentLength) && contentLength > MAX_BATCH_BYTES) {
30
51
  return json(
@@ -51,7 +72,8 @@ http.route({
51
72
  {
52
73
  error: {
53
74
  code: "INVALID_BODY",
54
- message: error instanceof Error ? error.message : "invalid JSON body",
75
+ message:
76
+ error instanceof Error ? error.message : "invalid JSON body",
55
77
  },
56
78
  },
57
79
  400,
@@ -59,12 +81,15 @@ http.route({
59
81
  );
60
82
  }
61
83
 
62
- const userAgent = request.headers.get("user-agent") ?? "";
84
+ const trustProxy = env.RASTRO_TRUST_PROXY === "true";
85
+ const metadata = await readRequestMetadata(ctx);
86
+ const proxyRequestTrusted = isTrustedProxyRequest(metadata.ip, trustProxy);
87
+ const clientIp = resolveClientIp(metadata.ip, request.headers, trustProxy);
88
+ const edgeGeo = proxyRequestTrusted ? geoFromHeaders(request) : {};
89
+ const userAgent =
90
+ metadata.userAgent ?? request.headers.get("user-agent") ?? "";
63
91
  const context = {
64
- country: firstHeader(request, ["x-vercel-ip-country", "cf-ipcountry"]),
65
- city: decodeHeader(firstHeader(request, ["x-vercel-ip-city", "cf-ipcity"])),
66
- latitude: numericHeader(request, ["x-vercel-ip-latitude", "cf-iplatitude"]),
67
- longitude: numericHeader(request, ["x-vercel-ip-longitude", "cf-iplongitude"]),
92
+ ...edgeGeo,
68
93
  ...classifyUserAgent(userAgent),
69
94
  };
70
95
 
@@ -75,6 +100,56 @@ http.route({
75
100
  events: payload.events,
76
101
  context,
77
102
  });
103
+ if (
104
+ clientIp.ip &&
105
+ !hasCoordinates(edgeGeo) &&
106
+ geoIpStatus(env.RASTRO_GEOIP_PROVIDER, env.RASTRO_GEOIP_TOKEN) ===
107
+ "ready"
108
+ ) {
109
+ const events = enrichmentCandidateEvents(payload.events);
110
+ if (events) {
111
+ let stage: GeoIpFailureStage = "claim";
112
+ try {
113
+ const claim = await ctx.runMutation(
114
+ internal.geo.claimEnrichment,
115
+ {
116
+ siteId: payload.siteId as Id<"sites">,
117
+ events,
118
+ dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
119
+ },
120
+ );
121
+ if (claim.sessionIds.length > 0) {
122
+ stage = "resolve";
123
+ const providerGeo = await resolveGeoIp(
124
+ clientIp.ip,
125
+ env.RASTRO_GEOIP_PROVIDER!,
126
+ env.RASTRO_GEOIP_TOKEN,
127
+ );
128
+ if (providerGeo) {
129
+ stage = "apply";
130
+ await ctx.runMutation(internal.geo.applyEnrichment, {
131
+ siteId: payload.siteId as Id<"sites">,
132
+ sessionIds: claim.sessionIds,
133
+ events: claim.events,
134
+ context: providerGeo,
135
+ });
136
+ } else {
137
+ logGeoIpFailure(
138
+ requestId,
139
+ stage,
140
+ env.RASTRO_GEOIP_PROVIDER!,
141
+ );
142
+ }
143
+ }
144
+ } catch {
145
+ logGeoIpFailure(
146
+ requestId,
147
+ stage,
148
+ env.RASTRO_GEOIP_PROVIDER!,
149
+ );
150
+ }
151
+ }
152
+ }
78
153
  return json(result, 200, headers);
79
154
  } catch (error) {
80
155
  if (error instanceof ConvexError) {
@@ -85,9 +160,25 @@ http.route({
85
160
  : data.code === "RATE_LIMITED"
86
161
  ? 429
87
162
  : 400;
88
- 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
+ );
89
175
  }
90
- 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
+ );
91
182
  }
92
183
  }),
93
184
  });
@@ -127,13 +218,35 @@ http.route({
127
218
  }),
128
219
  });
129
220
 
130
-
131
221
  http.route({
132
222
  path: "/health",
133
223
  method: "GET",
134
- handler: httpAction(async () => {
224
+ handler: httpAction(async (ctx, request) => {
225
+ const trustProxy = env.RASTRO_TRUST_PROXY === "true";
226
+ const metadata = await readRequestMetadata(ctx);
227
+ const proxyRequestTrusted = isTrustedProxyRequest(metadata.ip, trustProxy);
228
+ const clientIp = resolveClientIp(metadata.ip, request.headers, trustProxy);
229
+ const provider = env.RASTRO_GEOIP_PROVIDER;
135
230
  return json(
136
- { ok: true, component: "@iann29/rastro" },
231
+ {
232
+ ok: true,
233
+ component: "@iann29/rastro",
234
+ requestMetadata: {
235
+ runtimeIpKind: classifyIp(metadata.ip),
236
+ clientIpAvailable: clientIp.ip !== null,
237
+ clientIpSource: clientIp.source,
238
+ userAgentAvailable: metadata.userAgent !== null,
239
+ },
240
+ geoIp: {
241
+ provider: provider ?? null,
242
+ status: geoIpStatus(provider, env.RASTRO_GEOIP_TOKEN),
243
+ dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
244
+ trustedProxy: trustProxy,
245
+ proxyRequestTrusted,
246
+ edgeHeadersAvailable:
247
+ proxyRequestTrusted && Object.keys(geoFromHeaders(request)).length > 0,
248
+ },
249
+ },
137
250
  200,
138
251
  {
139
252
  "Access-Control-Allow-Origin": "*",
@@ -143,8 +256,15 @@ http.route({
143
256
  }),
144
257
  });
145
258
 
146
- function parsePayload(value: unknown): { siteId: string; events: TrackerEvent[] } {
147
- if (!isPlainRecord(value) || typeof value.siteId !== "string" || !Array.isArray(value.events)) {
259
+ function parsePayload(value: unknown): {
260
+ siteId: string;
261
+ events: TrackerEvent[];
262
+ } {
263
+ if (
264
+ !isPlainRecord(value) ||
265
+ typeof value.siteId !== "string" ||
266
+ !Array.isArray(value.events)
267
+ ) {
148
268
  throw new Error("body must contain siteId and events");
149
269
  }
150
270
  return { siteId: value.siteId, events: value.events.map(parseEvent) };
@@ -266,6 +386,90 @@ function optionalNumber(value: unknown, field: string): number | undefined {
266
386
  return value;
267
387
  }
268
388
 
389
+ function enrichmentCandidateEvents(events: TrackerEvent[]):
390
+ | Array<{
391
+ eventId: string;
392
+ sessionId: string;
393
+ visitorId: string;
394
+ timestamp: number;
395
+ sequence: number;
396
+ }>
397
+ | undefined {
398
+ if (events.length === 0 || events.length > MAX_BATCH_EVENTS) return undefined;
399
+ const now = Date.now();
400
+ const sanitized = events.flatMap((event) => {
401
+ try {
402
+ return [sanitizeEvent(event, now)];
403
+ } catch {
404
+ return [];
405
+ }
406
+ });
407
+ const sessionIds = [...new Set(sanitized.map((event) => event.sessionId))];
408
+ if (
409
+ !sanitized.some((event) => event.sequence === 0) ||
410
+ sessionIds.length > MAX_BATCH_EVENT_GROUPS ||
411
+ sessionIds.some(
412
+ (sessionId) => sessionId.length === 0 || sessionId.length > 128,
413
+ )
414
+ ) {
415
+ return undefined;
416
+ }
417
+ return sanitized.map(
418
+ ({ eventId, sessionId, visitorId, timestamp, sequence }) => ({
419
+ eventId,
420
+ sessionId,
421
+ visitorId,
422
+ timestamp,
423
+ sequence,
424
+ }),
425
+ );
426
+ }
427
+
428
+ function geoFromHeaders(request: Request): {
429
+ country?: string;
430
+ city?: string;
431
+ latitude?: number;
432
+ longitude?: number;
433
+ } {
434
+ const country = firstHeader(request, ["x-vercel-ip-country", "cf-ipcountry"]);
435
+ const city = decodeHeader(
436
+ firstHeader(request, ["x-vercel-ip-city", "cf-ipcity"]),
437
+ );
438
+ const latitude = numericHeader(request, [
439
+ "x-vercel-ip-latitude",
440
+ "cf-iplatitude",
441
+ ]);
442
+ const longitude = numericHeader(request, [
443
+ "x-vercel-ip-longitude",
444
+ "cf-iplongitude",
445
+ ]);
446
+ return {
447
+ ...(country ? { country } : {}),
448
+ ...(city ? { city } : {}),
449
+ ...(validCoordinates(latitude, longitude)
450
+ ? { latitude, longitude }
451
+ : {}),
452
+ };
453
+ }
454
+
455
+ function hasCoordinates(value: {
456
+ latitude?: number;
457
+ longitude?: number;
458
+ }): boolean {
459
+ return value.latitude !== undefined && value.longitude !== undefined;
460
+ }
461
+
462
+ function validCoordinates(
463
+ latitude: number | undefined,
464
+ longitude: number | undefined,
465
+ ): latitude is number {
466
+ return latitude !== undefined &&
467
+ longitude !== undefined &&
468
+ latitude >= -90 &&
469
+ latitude <= 90 &&
470
+ longitude >= -180 &&
471
+ longitude <= 180;
472
+ }
269
473
 
270
474
  function firstHeader(request: Request, names: string[]): string | undefined {
271
475
  for (const name of names) {
@@ -324,12 +528,45 @@ function classifyUserAgent(userAgent: string): {
324
528
  return { browser, os, device };
325
529
  }
326
530
 
327
- 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> {
328
563
  return {
329
564
  "Access-Control-Allow-Origin": origin,
330
565
  "Access-Control-Allow-Methods": "POST, OPTIONS",
331
566
  "Access-Control-Allow-Headers": "Content-Type",
567
+ "Access-Control-Expose-Headers": "X-Request-ID, Retry-After",
332
568
  "Access-Control-Max-Age": "86400",
569
+ ...(requestId ? requestIdHeaders(requestId) : {}),
333
570
  Vary: "Origin",
334
571
  };
335
572
  }