@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
package/README.md CHANGED
@@ -4,35 +4,80 @@
4
4
 
5
5
  Privacy-first, real-time web analytics built as an isolated Convex component.
6
6
 
7
- Amage Rastro combines a 1,000-byte gzip browser tracker with reactive traffic
7
+ Amage Rastro combines a 1.1 KB gzip browser tracker with reactive traffic
8
8
  metrics, a live visitor atlas, journey timelines, trusted payment attribution,
9
9
  goals, funnels, affiliates, and multi-site reports. Data and functions stay
10
10
  inside your Convex deployment.
11
11
 
12
12
  ![Amage Rastro Live Atlas](assets/rastro-live-atlas.png)
13
13
 
14
+ ## Project status
15
+
16
+ Amage Rastro is an actively developed alpha with a canonical batched event
17
+ store, protected CI, exact run-scoped benchmark reconciliation, and published
18
+ npm prereleases. The untagged `latest` channel intentionally remains on the
19
+ first public alpha. Federation consumers must statically inspect an
20
+ operator-approved exact registry artifact for every required runtime and type
21
+ export before changing a host; follow the gate in the
22
+ [federation setup runbook](docs/federation-setup.md). Never substitute a mutable
23
+ branch or website-supplied source tarball.
24
+
25
+ Public integration documentation is available at
26
+ [`www.amagerastro.com/docs/`](https://www.amagerastro.com/docs/). Coding agents
27
+ should start with [`llms.txt`](https://www.amagerastro.com/llms.txt) and verify
28
+ the [machine manifest](https://www.amagerastro.com/agent/manifest.json).
29
+
30
+ Current evidence supports more than 120 million stored events/day for realistic
31
+ traffic. Near-limit heavy payloads are certified at 76.62 million/day, and the
32
+ feature-saturated profile at 33.22 million/day. Workload-specific results and
33
+ caveats are reported separately rather than collapsed into one capacity claim.
34
+
14
35
  ## What ships
15
36
 
16
- | Capability | Implementation |
17
- | --------------------------- | --------------------------------------------------------------------------------------------------- |
18
- | Live visitor map | Reactive live sessions with coarse city-level coordinates and TTL expiry |
19
- | Traffic analytics | Sharded hour/day rollups, time series, top pages, sources, countries, devices, browsers, and events |
20
- | Journey replay | Bounded pageview/click/custom-event timelines; no DOM, keystroke, or form-value recording |
21
- | Payment conversion tracking | Trusted server-side ledger with idempotent event IDs and canonical site currency |
22
- | Goals and funnels | Exact pageview/custom-event goals and ordered conversion funnels |
23
- | Affiliates | Referral attribution, trusted revenue, and commission accounting |
24
- | Cross-site analytics | Reports over up to ten authorized site IDs with a preloaded pseudonymous visitor ID |
25
- | Auto-captured clicks | Anchors, buttons, outbound links, and explicit `data-rastro-event` controls |
26
- | Cookieless mode | Session-scoped pseudonymous IDs in `sessionStorage`; no cookies |
27
- | Tiny tracker | 1,846 bytes raw, 1,000 bytes gzip; the test suite enforces `< 1,024` bytes |
28
-
29
- ## Install
37
+ | Capability | Implementation |
38
+ | --------------------------- | -------------------------------------------------------------------------------------------------------- |
39
+ | Live visitor map | Reactive live sessions with coarse city-level coordinates and TTL expiry |
40
+ | Traffic analytics | Sharded hour/day rollups, time series, top pages, sources, countries, devices, browsers, and events |
41
+ | Journey replay | Bounded pageview/click/custom-event timelines; no DOM, keystroke, or form-value recording |
42
+ | Payment conversion tracking | Trusted server-side ledger with idempotent event IDs and canonical site currency |
43
+ | Goals and funnels | Exact pageview/custom-event goals and ordered conversion funnels |
44
+ | Affiliates | Referral attribution, trusted revenue, and commission accounting |
45
+ | Cross-site analytics | Reports over up to ten authorized site IDs with a preloaded pseudonymous visitor ID |
46
+ | Automatic GeoIP | Opt-in native request metadata plus IPinfo/IPWhois fallback; bounded quota and no raw-IP persistence |
47
+ | Auto-captured clicks | Anchors, buttons, and `data-rastro-event` controls with a bounded visible label; outbound origin+path |
48
+ | Bot filtering | Self-declared crawlers, unfurlers, monitors, HTTP libraries, and headless browsers rejected at ingestion |
49
+ | Cookieless mode | Session-scoped pseudonymous IDs in `sessionStorage`; no cookies |
50
+ | Tiny tracker | 2,132 bytes raw, 1,127 bytes gzip; the test suite enforces `< 1,280` bytes gzip |
51
+
52
+ ## Quickstart
53
+
54
+ ### Prerequisites
55
+
56
+ - Convex `^1.43.0` or a compatible Synapse deployment.
57
+ - An existing host authentication provider for administrative and local report
58
+ functions. The browser ingestion route itself is public and origin-checked.
59
+ - Two deployment origins recorded separately:
60
+
61
+ | Purpose | Convex Cloud example | Synapse example |
62
+ | ------------------------------ | ------------------------------ | -------------------------------------- |
63
+ | Functions and reactive queries | `https://project.convex.cloud` | `https://project.synapse.example` |
64
+ | HTTP actions and tracker | `https://project.convex.site` | `https://project.site.synapse.example` |
65
+
66
+ Never append `/rastro/tracker.js` to the functions URL.
67
+
68
+ ### 1. Install
30
69
 
31
70
  ```bash
32
- npm install @iann29/rastro
71
+ npm install @iann29/rastro@alpha
33
72
  ```
34
73
 
35
- Mount the component under an HTTP prefix:
74
+ The untagged npm `latest` channel intentionally remains on the first public
75
+ alpha. Use `@alpha` until a stable release is promoted.
76
+
77
+ ### 2. Mount the component
78
+
79
+ Merge the component into the project's existing configuration; preserve every
80
+ component and environment binding already registered there.
36
81
 
37
82
  ```ts
38
83
  // convex/convex.config.ts
@@ -52,6 +97,31 @@ This creates isolated component tables and four HTTP routes:
52
97
  - `GET /rastro/tracker.js` — the pre-gzipped tracker
53
98
  - `GET /rastro/health` — integration health
54
99
 
100
+ Push the component and regenerate host bindings before importing
101
+ `components.rastroAnalytics`:
102
+
103
+ ```bash
104
+ # Convex Cloud development deployment
105
+ npx convex dev
106
+
107
+ # Synapse development deployment
108
+ synapse dev --once
109
+ ```
110
+
111
+ For production, use the product's existing release workflow. On an uncoordinated
112
+ Convex project that is `npx convex deploy`; on Synapse use
113
+ `synapse release --execute` when the project has a release manifest, otherwise
114
+ `synapse deploy`. Never deploy before confirming the target.
115
+
116
+ Verify the HTTP origin independently from the functions origin:
117
+
118
+ ```bash
119
+ curl -fsS https://YOUR_HTTP_ORIGIN/rastro/health
120
+ ```
121
+
122
+ A successful health response proves that the component HTTP routes are mounted;
123
+ it does not prove that a browser event was accepted.
124
+
55
125
  ### Automatic GeoIP
56
126
 
57
127
  Rastro uses Convex request metadata as the canonical client IP and can resolve
@@ -80,9 +150,9 @@ export default app;
80
150
  `ipwhois` is also supported. Its token is optional for the documented
81
151
  1,000-request/day free endpoint and recommended for production. Bind it as
82
152
  `RASTRO_GEOIP_TOKEN` in the same way as the IPinfo example.
83
- `RASTRO_GEOIP_DAILY_LIMIT` defaults to 1,000 lookups per site per UTC day
84
- and may be set from `0` through `1000000` to match the provider plan. Admission
85
- is transactional, so concurrent first-session requests cannot exceed the cap.
153
+ `RASTRO_GEOIP_DAILY_LIMIT` defaults to 1,000 lookups per site per UTC day and
154
+ may be set from `0` through `1000000` to match the provider plan. Admission is
155
+ transactional, so concurrent first-session requests cannot exceed the cap.
86
156
 
87
157
  When Rastro runs behind a reverse proxy that supplies `X-Forwarded-For` or
88
158
  Vercel/Cloudflare geographic headers, explicitly bind
@@ -100,13 +170,47 @@ sanitized country, city, and coordinates rounded to one decimal place are
100
170
  persisted.
101
171
 
102
172
  `GET /rastro/health` reports whether the runtime exposes a usable client IP and
103
- whether GeoIP is configured and the daily cap, without returning the address or token. Deployment
104
- administrators can exercise the configured provider through the internal
105
- `geo:probe` action. If health reports no usable client IP, use trusted
106
- Vercel/Cloudflare geographic headers with `RASTRO_TRUST_PROXY` or fix the
107
- deployment's proxy forwarding.
108
-
109
- ## Create a site
173
+ whether GeoIP is configured and the daily cap, without returning the address or
174
+ token. Deployment administrators can exercise the configured provider through
175
+ the internal `geo:probe` action. If health reports no usable client IP, use
176
+ trusted Vercel/Cloudflare geographic headers with `RASTRO_TRUST_PROXY` or fix
177
+ the deployment's proxy forwarding.
178
+
179
+ ### Browser and device classification
180
+
181
+ The tracker sends no client identification. Ingestion derives `browser`, `os`,
182
+ and `device` from headers the browser attaches itself, and persists only those
183
+ three normalized values; the raw `User-Agent` never reaches a mutation or a
184
+ component table.
185
+
186
+ Chromium attaches the low-entropy `Sec-CH-UA`, `Sec-CH-UA-Mobile`, and
187
+ `Sec-CH-UA-Platform` client hints to cross-origin requests from secure origins,
188
+ with no `Accept-CH` opt-in required of the tracked site. They are preferred when
189
+ present, because they are the only signal separating Brave, Opera, Vivaldi,
190
+ Samsung Internet, Yandex, and bare Chromium from Chrome: Brave ships Chrome's
191
+ `User-Agent` verbatim by design. Hint brands are allowlisted, so an unrecognized
192
+ or forged brand falls back to the `User-Agent` instead of entering the browser
193
+ dimension verbatim.
194
+
195
+ The `User-Agent` remains the fallback for Firefox and Safari, which implement no
196
+ client hints, and for insecure origins, which receive none. Known limits of that
197
+ path:
198
+
199
+ - iPadOS 13+ requests sites with a Macintosh `User-Agent` by default and Safari
200
+ sends no hints, so those sessions are reported as desktop macOS.
201
+ - Without hints every Android client is reported as mobile;
202
+ `Sec-CH-UA-Mobile: ?0` on the Android platform is the only available tablet
203
+ signal, because the explicit `Sec-CH-UA-Form-Factors` hint is high entropy and
204
+ would require an `Accept-CH` opt-in and a permissions-policy delegation from
205
+ every tracked site.
206
+ - Known bots are rejected at ingestion by `User-Agent`: self-declared crawlers,
207
+ link unfurlers, uptime monitors, HTTP libraries, headless browsers, and any
208
+ agent carrying a URL. The response keeps the usual counters with `rejected`
209
+ set and adds `X-Rastro-Rejected: bot`; overview metadata reports
210
+ `botDetection` as `userAgent`. A scripted browser that spoofs a stock
211
+ `User-Agent` is still counted as an ordinary session.
212
+
213
+ ### 3. Create a site
110
214
 
111
215
  Components cannot read the host app's authentication state. The host must
112
216
  authenticate the caller and pass an opaque owner ID.
@@ -132,7 +236,7 @@ export const createSite = mutation({
132
236
  if (!identity) throw new Error("Not authenticated");
133
237
 
134
238
  return await analytics.createSite(ctx, {
135
- ownerId: identity.subject,
239
+ ownerId: identity.tokenIdentifier,
136
240
  name: args.name,
137
241
  domains: args.domains,
138
242
  currency: args.currency,
@@ -142,12 +246,30 @@ export const createSite = mutation({
142
246
  });
143
247
  ```
144
248
 
249
+ Call this mutation from the host application's authenticated administration UI
250
+ and persist or display the returned `siteId`:
251
+
252
+ ```tsx
253
+ const createSite = useMutation(api.analyticsAdmin.createSite);
254
+ const siteId = await createSite({
255
+ name: "Amage Product",
256
+ domains: ["product.example.com", "localhost"],
257
+ currency: "BRL",
258
+ });
259
+ ```
260
+
261
+ Do not call the authenticated example with a bare CLI command: without an
262
+ explicit test identity, `ctx.auth.getUserIdentity()` correctly returns `null`.
263
+
145
264
  Allowed domains are checked at ingestion. Exact hosts and wildcard subdomains
146
265
  such as `*.example.com` are supported. `Origin` is an abuse signal, not
147
266
  authentication; Amage Rastro also enforces per-session and fixed-shard per-site
148
267
  event/byte budgets.
149
268
 
150
- ## Add the tracker
269
+ Domain entries are hostnames, not host-and-port pairs. Use `localhost` or a full
270
+ origin such as `http://localhost:3000`; both normalize to the `localhost` host.
271
+
272
+ ### 4. Add the tracker
151
273
 
152
274
  Use the HTTP-actions URL for your deployment, not the Convex function URL.
153
275
 
@@ -179,6 +301,9 @@ serves the script and ingestion from different origins:
179
301
 
180
302
  ### React
181
303
 
304
+ React is an optional peer dependency. It is required only when importing the
305
+ `@iann29/rastro/react` entry point; backend-only consumers do not need it.
306
+
182
307
  ```tsx
183
308
  import { useState } from "react";
184
309
  import { RastroScript, useRastro } from "@iann29/rastro/react";
@@ -208,6 +333,26 @@ the same value on authorized sites to connect their journeys. Amage Rastro
208
333
  intentionally has no mutable post-load `identify` command: changing identity
209
334
  after a session starts would make the session ambiguous.
210
335
 
336
+ ### 5. Verify ingestion
337
+
338
+ 1. Open an allowed site origin in a fresh browser tab.
339
+ 2. In browser developer tools, filter Network requests by `rastro/events`.
340
+ 3. Wait for the normal tracker flush, which is debounced by approximately 1.2
341
+ seconds.
342
+ 4. Confirm `POST /rastro/events` returns HTTP 200 and a response containing
343
+ `accepted`, `duplicates`, and `rejected` counters.
344
+ 5. Run one authenticated local report, such as `analytics:overview`, for the
345
+ returned `siteId` and a complete UTC hour/day range.
346
+
347
+ The tracker intentionally does not interrupt the product UI when transport
348
+ fails. A loaded `tracker.js` or successful `/health` response alone is not proof
349
+ of ingestion. The usual causes of a rejected or missing request are:
350
+
351
+ - using the functions URL instead of the HTTP-actions URL;
352
+ - omitting the exact browser host from the site's `domains`;
353
+ - including query strings or paths in a configured base URL;
354
+ - querying a partial UTC bucket or a site the authenticated user does not own.
355
+
211
356
  ## Track custom events
212
357
 
213
358
  ```js
@@ -222,6 +367,12 @@ For declarative click capture:
222
367
  </button>
223
368
  ```
224
369
 
370
+ Auto-captured clicks record the control's visible text, bounded to 64 characters
371
+ and whitespace-normalized, so journeys read "Clicked «View pricing»" without
372
+ annotation. `data-rastro-label` replaces that text; use it on controls whose
373
+ copy may contain personal data. Input values are never read. Outbound links keep
374
+ their origin and pathname; internal links keep the pathname.
375
+
225
376
  Properties accept bounded string, number, boolean, or null values. Never send
226
377
  emails, names, form values, secrets, or other personal data as analytics
227
378
  properties.
@@ -232,6 +383,46 @@ session or with a timestamp in another minute is allowed. The tracker preserves
232
383
  timestamps when retrying. Trusted payment event IDs remain separately
233
384
  payload-bound by the site's financial ledger.
234
385
 
386
+ ## Attribute campaigns
387
+
388
+ The tracker reads `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, and
389
+ `utm_content` from the landing URL, keeps them in `sessionStorage` for the rest
390
+ of the session, and sends them as properties of every pageview; no other query
391
+ parameter ever leaves the page. A session's `source` is the lowercase
392
+ `utm_source` when present and the referrer host otherwise, so campaign traffic
393
+ no longer reads as `direct`. `utmSource`, `utmMedium`, and `utmCampaign` stay on
394
+ the session for journeys, and `overview.topCampaigns` ranks `utm_campaign` by
395
+ event volume next to `topSources`.
396
+
397
+ ## Identify visitors after signup
398
+
399
+ Anonymous sessions carry a random `visitorId` until the host supplies a
400
+ pseudonymous one through `data-visitor` or the `RastroScript` `visitorId` prop.
401
+ To keep the anonymous history when a visitor creates an account or signs in,
402
+ link the two ids from the trusted server path, never from the browser:
403
+
404
+ ```js
405
+ const { visitorId: anonymousVisitorId } = window.rastro("context");
406
+ // Send anonymousVisitorId with the signup or sign-in request.
407
+ ```
408
+
409
+ ```ts
410
+ await analytics.linkVisitor(ctx, {
411
+ siteId,
412
+ visitorId: user.analyticsId, // the value later passed to the tracker
413
+ previousVisitorId: anonymousVisitorId,
414
+ });
415
+ ```
416
+
417
+ `visitorJourney` for either id then returns one chronological journey; every
418
+ event keeps the id it was recorded with. Links are idempotent, an anonymous id
419
+ belongs to at most one identity, a linked id cannot be linked again, chains and
420
+ identity merges are rejected, and an identity holds at most ten aliases. The
421
+ tracker still sets no cookie: the host's own session identifies the account and
422
+ the anonymous id lives only in `sessionStorage`. Linking anonymous history to an
423
+ account turns it into personal data under the host's privacy policy; disclose it
424
+ and offer an opt-out.
425
+
235
426
  ## Attribute trusted payments
236
427
 
237
428
  Browser-created conversion events are untrusted telemetry and never affect
@@ -300,12 +491,12 @@ export const {
300
491
 
301
492
  for (const siteId of request.siteIds) {
302
493
  const site = await analytics.getSite(ctx, siteId);
303
- if (!site || site.ownerId !== identity.subject) {
494
+ if (!site || site.ownerId !== identity.tokenIdentifier) {
304
495
  throw new Error("Not authorized for analytics site");
305
496
  }
306
497
  }
307
498
 
308
- if (request.ownerId && request.ownerId !== identity.subject) {
499
+ if (request.ownerId && request.ownerId !== identity.tokenIdentifier) {
309
500
  throw new Error("Not authorized for analytics owner");
310
501
  }
311
502
  },
@@ -316,25 +507,83 @@ Do not copy the unauthenticated demo boundary into production. The included demo
316
507
  exposes only fixed-scope read reports plus bounded, idempotent seed/live-refresh
317
508
  functions.
318
509
 
510
+ ### Federated dashboard backend
511
+
512
+ The reference control plane in `control-plane/` can connect a central dashboard
513
+ while telemetry remains in the customer's deployment. It uses
514
+ `exposeFederatedAnalyticsApi` with issuer and connection-resolver options. The
515
+ helper constructs its authorization boundary internally; it cannot accept a
516
+ permissive host authorizer. Its surface contains a public protocol v1 manifest,
517
+ authenticated redacted connection status, redacted site summaries, and only the
518
+ reactive dashboard report queries.
519
+
520
+ The JWT only identifies the requested connection. Site access remains
521
+ authoritative in a local host record resolved on every query, so revocation is
522
+ immediate and a token cannot grant itself another site. `listSites` accepts no
523
+ site arguments and omits owner, domain, and network configuration. Stable
524
+ manifest names, capabilities, limits, and authorization error codes are exported
525
+ for connector clients. The host must already have Convex authentication and a
526
+ local authoritative grant. Follow
527
+ [`docs/federation-setup.md`](docs/federation-setup.md) for the executable setup
528
+ and verification sequence; use [`docs/federation.md`](docs/federation.md) as the
529
+ protocol reference. The production control plane is available at
530
+ `https://www.amagerastro.com`; before connecting a customer deployment, verify
531
+ the selected package exports the federation helper as shown in the setup
532
+ runbook.
533
+
319
534
  ## Backend API
320
535
 
321
536
  The `Rastro` class wraps the component boundary for host functions:
322
537
 
323
538
  - Sites: `createSite`, `updateSite`, `getSite`, `listSites`
324
- - Ingestion: `ingest`, `trackConversion`
539
+ - Ingestion: `ingest`, `trackConversion`, `linkVisitor`
325
540
  - Reports: `overview`, `liveVisitors`, `listSessions`, `sessionJourney`,
326
541
  `listConversions`, `visitorJourney`, `goalsReport`, `funnelsReport`,
327
- `affiliatesReport`
542
+ `affiliatesReport`, `dataCoverage`
328
543
  - Configuration: `upsertGoal`, `listGoals`, `removeGoal`, `upsertFunnel`,
329
544
  `listFunnels`, `removeFunnel`, `upsertAffiliate`, `listAffiliates`,
330
545
  `removeAffiliate`
331
- - Retention: `cleanup`
546
+ - Retention: `cleanup`, `setRetentionPolicy`, `disableRetentionPolicy`,
547
+ `runRetentionPolicy`, `retentionStatus`
332
548
 
333
549
  Every public function has argument and return validators. Growing reads are
334
550
  indexed and bounded. Component pagination uses scope-bound opaque keyset cursors
335
551
  because native `.paginate()` is not supported inside Convex components; page
336
552
  cost stays constant instead of increasing with an offset.
337
553
 
554
+ Overview responses label visitor values as either HLL-estimated unique visitors
555
+ or legacy `bucketOccurrences`; legacy aggregate rows are never presented as
556
+ exact uniques. Anonymous sessions from the same device and network within one
557
+ UTC day count as one visitor through a salted daily key, so cookieless visitor
558
+ counts no longer collapse into session counts; runtimes without a client IP fall
559
+ back to one visitor per session, which `/rastro/health` reports as
560
+ `uniqueVisitors: "session"` instead of `"dailyHash"`. Breakdown metadata
561
+ likewise identifies page, source, country, device, browser, and event rankings
562
+ as event-volume counts. Feature status is explicit: outbound-link event volume
563
+ is supported, bot detection rejects self-declared agents by `User-Agent`, and
564
+ session replay and error insights are currently unsupported.
565
+
566
+ All report ranges use integer Unix milliseconds with inclusive `from` and `to`
567
+ boundaries. Range metadata reports `boundary: "inclusive"` and
568
+ `timezone: "UTC"`; the site's timezone is presentation metadata and never
569
+ changes report bucket boundaries. Overview queries must contain complete UTC
570
+ hours or days, and funnel and affiliate reports must contain complete UTC days.
571
+ Those aggregate-backed reports fail with `REPORT_INCOMPLETE` instead of silently
572
+ including the rest of a partial bucket. Goal reports retain exact millisecond
573
+ filtering while bounded goal-completion rows are available.
574
+
575
+ Goal, funnel, and affiliate reports use definition-ID keyed daily rollups when
576
+ the low-churn rollout control proves that generation is available. Each loader
577
+ enforces day, row, document, and byte headroom before returning a complete
578
+ result; it never returns a silently truncated report. Ordinary ingestion never
579
+ patches or schedules work against a shared coverage document. `dataCoverage`
580
+ derives availability through bounded indexed reads of source and rollup records,
581
+ while the control document stores only rollout generation and durable retention
582
+ watermarks. Heartbeats can advance session availability but do not claim event
583
+ or aggregate availability. Hourly and daily overview availability are reported
584
+ separately as `overviewHour` and `overviewDay` because their retention
585
+ boundaries use different bucket alignment.
586
+
338
587
  ## Operational limits
339
588
 
340
589
  - HTTP body: 64 KiB
@@ -344,16 +593,25 @@ cost stays constant instead of increasing with an offset.
344
593
  - Primary aggregate shards: 256 hourly and 128 daily, derived from stable
345
594
  session identity
346
595
  - Funnel and affiliate aggregate shards: 16
347
- - Site ingress budget: 4,096 deterministic per-minute shards, each capped at
348
- 300 events and 512 KiB (1,228,800 admitted events/minute at uniform load)
596
+ - Site ingress budget: 4,096 deterministic per-minute shards, each capped at 300
597
+ events and 512 KiB (1,228,800 admitted events/minute at uniform load)
349
598
  - Rate-window storage: one reusable row per active session and site shard
350
599
  - Live session timeout: 60 seconds with one bounded five-second expiry sweep per
351
600
  site; legacy per-session jobs hand off to that sweep during the cutover
352
601
  - Dashboard overview: up to ten sites and bounded report ranges
353
602
  - Hourly overview: up to 24 hours; wider ranges use daily buckets
354
- - Retention cleanup: bounded batches; schedule host maintenance for the data
355
- classes your policy retains. The `events` cleanup kind removes canonical
356
- event-batch documents and processes at most 32 heavy batches per transaction.
603
+ - Retention cleanup: bounded batches. `setRetentionPolicy` automatically starts
604
+ leased, persisted cleanup chains for every policy-managed dataset; interrupted
605
+ attempts resume after lease expiry and stale generations cannot mutate the
606
+ active chain. A completed chain schedules its next cycle after one day and
607
+ recomputes its cutoff from the then-current policy. Policy updates replace the
608
+ generation, and `disableRetentionPolicy` makes every queued attempt stale.
609
+ `runRetentionPolicy` remains available for an explicit bounded run. The
610
+ `events` cleanup kind removes canonical event-batch documents and processes at
611
+ most 32 heavy batches per transaction.
612
+ - Retention cutoffs are bucket-safe: event minutes, hourly aggregates, daily
613
+ aggregates and feature rollups preserve the bucket containing a cutoff and
614
+ return the exact effective `before` boundary they applied.
357
615
 
358
616
  Capacity claims use declared workload profiles. Complete release evidence
359
617
  reports ten-minute `lean`, `realistic`, `heavy`, and browser `feature` profiles
@@ -361,12 +619,52 @@ separately. The browser `feature` profile exercises configured goals, funnels,
361
619
  and affiliate attribution; trusted conversions require a separate server-side
362
620
  driver and certification result.
363
621
 
364
- The historical development evidence and its certification caveats are recorded
365
- in [`docs/benchmarks/2026-08-20-realistic.md`](docs/benchmarks/2026-08-20-realistic.md).
622
+ ### Historical ingest evidence requiring recertification
623
+
624
+ | Profile | Workload | Certified stored/day | p95 latency | 100M/day target |
625
+ | --------- | ------------------------------------------------- | -------------------: | ----------: | --------------- |
626
+ | Lean | Minimal events, maximum batches | 136.65M | 4,543 ms | Pass |
627
+ | Realistic | Reused sessions, browser mix, context, heartbeats | 120.81M | 4,247 ms | Pass |
628
+ | Heavy | 62 KiB requests with bounded custom properties | 76.62M | 3,670 ms | Below target |
629
+ | Feature | Goals, funnels, and affiliate attribution | 33.22M | 6,226 ms | Below target |
630
+
631
+ Every listed run lasted at least ten minutes, completed with zero request
632
+ failures, duplicates, or rejected events, and exactly reconciled the expected
633
+ stored events. Results came from one client process against a non-production
634
+ Synapse DEV deployment; they are engineering evidence, not a production SLA. The
635
+ heavy row is the formal recertification after removing redundant payload
636
+ serialization. GeoIP with a provider enabled and trusted server-side conversions
637
+ remain separate pending certifications.
638
+
639
+ These runs predate the current source-derived coverage and recurring-retention
640
+ changes. They remain historical evidence for the named revisions and profiles,
641
+ not certification of this uncommitted implementation. Recertify the final code
642
+ before presenting any row as current capacity.
643
+
644
+ The latest formal results are recorded in
645
+ [`docs/benchmarks/2026-08-21-formal-certification.md`](docs/benchmarks/2026-08-21-formal-certification.md).
646
+ Earlier development evidence remains available in
647
+ [`docs/benchmarks/2026-08-20-realistic.md`](docs/benchmarks/2026-08-20-realistic.md).
366
648
 
367
649
  From a repository checkout, run a duration-based profile against a confirmed
368
650
  non-production deployment and a dedicated idle benchmark site:
369
651
 
652
+ For a deployment-free CI smoke benchmark of write-heavy ingestion plus reactive
653
+ overview and feature report queries, run:
654
+
655
+ ```bash
656
+ npm run benchmark:local
657
+ ```
658
+
659
+ The local harness uses `convex-test`, enforces a 12-document ingest transaction
660
+ budget, checks reactive totals before and after a write, and queries proven
661
+ daily rollups with the maximum configured 50 goals, 20 funnels, and 100
662
+ affiliates under deterministic 1,000-document and 4 MiB read ceilings. It also
663
+ drives concurrent ingest calls and asserts that their shared rollout/retention
664
+ control document is unchanged. It fails when its generous CI latency budgets are
665
+ exceeded. This is a deterministic local regression guard, not evidence of live
666
+ deployment throughput or capacity.
667
+
370
668
  ```bash
371
669
  npm run --silent benchmark:ingest -- \
372
670
  --url https://example.site/rastro/events \
@@ -375,7 +673,8 @@ npm run --silent benchmark:ingest -- \
375
673
  --origin https://your-allowed-origin.example \
376
674
  --profile realistic \
377
675
  --duration 600 \
378
- --concurrency 100 \
676
+ --concurrency 75 \
677
+ --batch-size 50 \
379
678
  --certify
380
679
  ```
381
680
 
@@ -390,10 +689,10 @@ generates exact session IDs from a cryptographic run UUID and reads them through
390
689
  bounded index lookups, so unrelated traffic cannot alter the result. The query
391
690
  uses the session counters persisted transactionally with event batches; it
392
691
  certifies ingest accounting, not raw-event availability after later retention.
393
- Reports distinguish accepted ingress, heartbeats, and stored events.
394
- Shorter completed runs report extrapolated daily estimates only. `--certify`
395
- requires at least ten minutes and successful reconciliation before emitting
396
- certified daily capacity fields or exiting successfully as a certification.
692
+ Reports distinguish accepted ingress, heartbeats, and stored events. Shorter
693
+ completed runs report extrapolated daily estimates only. `--certify` requires at
694
+ least ten minutes and successful reconciliation before emitting certified daily
695
+ capacity fields or exiting successfully as a certification.
397
696
 
398
697
  The ingestion transaction folds metric/dimension deltas, session rate counters,
399
698
  site-shard counters, session/live state, visitor attribution, and exact funnel
@@ -407,16 +706,15 @@ Click/outbound/browser-conversion telemetry skips goal and funnel work that
407
706
  cannot match those event types.
408
707
 
409
708
  Raw telemetry is stored only in immutable `eventBatches`. Migrated events retain
410
- their original per-event browser and geo context inside those batches, while
411
- new events hydrate context from their session. Journey `_id` values are stable
709
+ their original per-event browser and geo context inside those batches, while new
710
+ events hydrate context from their session. Journey `_id` values are stable
412
711
  strings derived from batch and nested-event identity, and session-journey
413
712
  cursors use timestamp, creation time, and ID.
414
713
 
415
714
  Existing deployments that still contain the retired `events` table must not
416
- upgrade directly to this cutover. First deploy the migration-capable predecessor,
417
- run its documented migration until both durable success and an empty source are
418
- reported, and only then install the canonical-only release. Fresh deployments
419
- need no migration.
715
+ upgrade directly to this cutover. Follow the exact intermediate release and
716
+ verification sequence in [`docs/upgrading.md`](docs/upgrading.md). Fresh
717
+ deployments need no legacy-event migration.
420
718
 
421
719
  ## Privacy contract
422
720
 
@@ -425,13 +723,20 @@ Amage Rastro's default tracker:
425
723
  - sets no cookies;
426
724
  - stores a pseudonymous session ID and referral slug in `sessionStorage`;
427
725
  - never persists raw visitor IP addresses in component tables;
726
+ - counts anonymous unique visitors through a salted hash of the request IP and
727
+ `User-Agent` that rotates every UTC day, derived in the HTTP action from a
728
+ per-deployment secret; a host-supplied `visitorId` always takes precedence,
729
+ and the raw IP never reaches a mutation or a table;
730
+ - never persists the raw `User-Agent`, only a normalized browser, operating
731
+ system, and device;
428
732
  - processes an IP only when an edge supplies geography or an operator enables a
429
733
  GeoIP provider, which receives that IP under its own privacy terms;
430
734
  - stores only country, city, and coordinates rounded to one decimal place;
431
- - does not record the DOM, screenshots, keystrokes, form values, or arbitrary
432
- page text;
433
- - captures only bounded paths, explicit properties, button/anchor targets, and
434
- outbound pathnames;
735
+ - does not record the DOM, screenshots, keystrokes, form values, input contents,
736
+ or arbitrary page text;
737
+ - captures only bounded paths, explicit properties, the visible label of a
738
+ clicked link or button (at most 64 characters, replaced by `data-rastro-label`
739
+ when present), and outbound link origins and pathnames;
435
740
  - strips query strings and fragments from stored paths and links.
436
741
 
437
742
  Cookieless does not automatically mean consent-exempt in every jurisdiction.
@@ -454,6 +759,10 @@ synapse dev --once
454
759
  npm run dev:frontend
455
760
  ```
456
761
 
762
+ The root `convex.json` targets the control plane in `control-plane/convex`. The
763
+ reference consumer host in `example/` is a separate Convex app; see
764
+ [`example/README.md`](example/README.md) to run, seed, and pair it.
765
+
457
766
  Quality gates:
458
767
 
459
768
  ```bash
@@ -461,20 +770,23 @@ npm test
461
770
  npm run typecheck
462
771
  npm run lint
463
772
  npm run build
464
- cd example && npx vite build
773
+ npm exec vite -- build example
465
774
  ```
466
775
 
467
776
  The test suite covers origin enforcement, event/privacy sanitization,
468
777
  idempotency, reusable session/site rate limits, bounded live expiry chains,
469
778
  folded aggregate writes, goals, funnels, trusted payments, currency safety,
470
779
  affiliate commission, cross-site journeys, keyset pagination beyond 1,000 rows,
471
- retention, HTTP routes, React integration, and tracker size/behavior.
780
+ retention, request metadata and GeoIP, exact byte boundaries, HTTP routes, React
781
+ integration, and tracker size/behavior.
472
782
 
473
- ## Synapse demo
783
+ ## Synapse deployments
474
784
 
475
- The active private development project is `ianProjects / Rastro Analytics`. The
476
- public-facing site URL is resolved from `.env.local`; credentials and
477
- `.synapse/` are gitignored.
785
+ The repository root is linked to the private Synapse project whose DEV and PROD
786
+ deployments run the control plane in `control-plane/`. The public-facing site
787
+ URL is resolved from `.env.local`; credentials and `.synapse/` are gitignored.
788
+ The consumer host in `example/` is a separate Convex app with its own deployment
789
+ link; run its CLI commands from that directory.
478
790
 
479
791
  ## License
480
792