@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (179) hide show
  1. package/README.md +693 -69
  2. package/agent/integration.md +801 -0
  3. package/agent/manifest.json +205 -0
  4. package/agent/manifest.schema.json +444 -0
  5. package/dist/client/federation.d.ts +381 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +274 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2673 -15
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +565 -33
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +22 -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 +315 -4
  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/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +28 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +44 -1
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +27 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +56 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +21 -9
  47. package/dist/component/eventStore.d.ts.map +1 -1
  48. package/dist/component/eventStore.js +142 -152
  49. package/dist/component/eventStore.js.map +1 -1
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +73 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +648 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +8 -5
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +281 -62
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +498 -95
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts.map +1 -1
  74. package/dist/component/live.js +7 -6
  75. package/dist/component/live.js.map +1 -1
  76. package/dist/component/localTime.d.ts +25 -0
  77. package/dist/component/localTime.d.ts.map +1 -0
  78. package/dist/component/localTime.js +126 -0
  79. package/dist/component/localTime.js.map +1 -0
  80. package/dist/component/reports.d.ts +266 -10
  81. package/dist/component/reports.d.ts.map +1 -1
  82. package/dist/component/reports.js +1243 -137
  83. package/dist/component/reports.js.map +1 -1
  84. package/dist/component/retention.d.ts +75 -1
  85. package/dist/component/retention.d.ts.map +1 -1
  86. package/dist/component/retention.js +561 -38
  87. package/dist/component/retention.js.map +1 -1
  88. package/dist/component/rollupStore.d.ts +320 -0
  89. package/dist/component/rollupStore.d.ts.map +1 -0
  90. package/dist/component/rollupStore.js +596 -0
  91. package/dist/component/rollupStore.js.map +1 -0
  92. package/dist/component/rollups.d.ts +20 -0
  93. package/dist/component/rollups.d.ts.map +1 -0
  94. package/dist/component/rollups.js +73 -0
  95. package/dist/component/rollups.js.map +1 -0
  96. package/dist/component/sanitize.d.ts +24 -1
  97. package/dist/component/sanitize.d.ts.map +1 -1
  98. package/dist/component/sanitize.js +96 -16
  99. package/dist/component/sanitize.js.map +1 -1
  100. package/dist/component/schema.d.ts +687 -65
  101. package/dist/component/schema.js +187 -20
  102. package/dist/component/schema.js.map +1 -1
  103. package/dist/component/sites.d.ts +12 -0
  104. package/dist/component/sites.d.ts.map +1 -1
  105. package/dist/component/sites.js +41 -7
  106. package/dist/component/sites.js.map +1 -1
  107. package/dist/component/useragent.d.ts +9 -0
  108. package/dist/component/useragent.d.ts.map +1 -0
  109. package/dist/component/useragent.js +152 -0
  110. package/dist/component/useragent.js.map +1 -0
  111. package/dist/component/validators.d.ts +124 -69
  112. package/dist/component/validators.d.ts.map +1 -1
  113. package/dist/component/validators.js +35 -14
  114. package/dist/component/validators.js.map +1 -1
  115. package/dist/component/visitors.d.ts +19 -0
  116. package/dist/component/visitors.d.ts.map +1 -0
  117. package/dist/component/visitors.js +86 -0
  118. package/dist/component/visitors.js.map +1 -0
  119. package/dist/component/vitals.d.ts +41 -0
  120. package/dist/component/vitals.d.ts.map +1 -0
  121. package/dist/component/vitals.js +115 -0
  122. package/dist/component/vitals.js.map +1 -0
  123. package/dist/react/index.d.ts.map +1 -1
  124. package/dist/react/index.js.map +1 -1
  125. package/dist/tracker/generated.d.ts +11 -4
  126. package/dist/tracker/generated.d.ts.map +1 -1
  127. package/dist/tracker/generated.js +11 -4
  128. package/dist/tracker/generated.js.map +1 -1
  129. package/dist/tracker/tracker.d.ts +1 -1
  130. package/dist/tracker/tracker.d.ts.map +1 -1
  131. package/dist/tracker/tracker.js +57 -20
  132. package/dist/tracker/tracker.js.map +1 -1
  133. package/dist/tracker/vitals.d.ts +10 -0
  134. package/dist/tracker/vitals.d.ts.map +1 -0
  135. package/dist/tracker/vitals.js +140 -0
  136. package/dist/tracker/vitals.js.map +1 -0
  137. package/dist/tracker.min.js +1 -1
  138. package/dist/vitals.min.js +1 -0
  139. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  140. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  141. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  142. package/docs/federation-setup.md +464 -0
  143. package/docs/federation.md +352 -0
  144. package/docs/upgrading.md +344 -0
  145. package/llms.txt +72 -0
  146. package/package.json +55 -11
  147. package/scripts/benchmark-ingest.mjs +175 -73
  148. package/scripts/generate-federation-keys.mjs +20 -0
  149. package/src/component/_generated/api.ts +22 -0
  150. package/src/component/_generated/component.ts +381 -4
  151. package/src/component/_generated/server.ts +4 -0
  152. package/src/component/affiliates.ts +20 -5
  153. package/src/component/cardinality.ts +117 -0
  154. package/src/component/constants.ts +44 -1
  155. package/src/component/convex.config.ts +11 -1
  156. package/src/component/coverage.ts +71 -0
  157. package/src/component/diagnostics.ts +65 -0
  158. package/src/component/errors.ts +2 -1
  159. package/src/component/eventStore.ts +217 -193
  160. package/src/component/funnels.ts +19 -16
  161. package/src/component/geo.ts +835 -0
  162. package/src/component/goals.ts +29 -21
  163. package/src/component/guards.ts +3 -1
  164. package/src/component/http.ts +404 -70
  165. package/src/component/identity.ts +74 -0
  166. package/src/component/ingest.ts +894 -188
  167. package/src/component/live.ts +13 -7
  168. package/src/component/localTime.ts +167 -0
  169. package/src/component/reports.ts +1872 -197
  170. package/src/component/retention.ts +788 -96
  171. package/src/component/rollupStore.ts +799 -0
  172. package/src/component/rollups.ts +82 -0
  173. package/src/component/sanitize.ts +144 -29
  174. package/src/component/schema.ts +217 -21
  175. package/src/component/sites.ts +59 -12
  176. package/src/component/useragent.ts +171 -0
  177. package/src/component/validators.ts +49 -14
  178. package/src/component/visitors.ts +116 -0
  179. package/src/component/vitals.ts +146 -0
package/README.md CHANGED
@@ -4,35 +4,91 @@
4
4
 
5
5
  Privacy-first, real-time web analytics built as an isolated Convex component.
6
6
 
7
- Amage Rastro combines a 1,022-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 140 million stored events/day for realistic
31
+ traffic and more than 178 million for lean traffic. Near-limit heavy payloads
32
+ are certified at 55.26 million/day on a data-heavy store (76.62 million/day on a
33
+ fresh store, 2026-08-22). The feature-saturated profile failed its last
34
+ certification on a rollup-contention defect
35
+ ([#52](https://github.com/amageweb/amage-rastro/issues/52)); the contended write
36
+ path no longer exists (report rollups are folded by one job per site, off the
37
+ ingestion transaction) and the profile awaits recertification. Workload-specific
38
+ results and caveats are reported separately rather than collapsed into one
39
+ capacity claim.
40
+
14
41
  ## What ships
15
42
 
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,909 bytes raw, 1,022 bytes gzip; the test suite enforces `< 1,024` bytes |
28
-
29
- ## Install
43
+ | Capability | Implementation |
44
+ | --------------------------- | --------------------------------------------------------------------------------------------------------- |
45
+ | Live visitor map | Reactive live sessions with city-level coordinates, realtime departures, and a 90-second safety net |
46
+ | Traffic analytics | Sharded hour/day rollups, time series, top pages, sources, countries, devices, browsers, and events |
47
+ | Field Web Vitals | Opt-in tracker module reporting LCP, CLS, INP, FCP, and TTFB into bounded per-page daily histograms |
48
+ | Site map | Daily per-route rollups: entries, derived exits and bounces, time on page, and route-to-route transitions |
49
+ | Journey replay | Bounded pageview/click/custom-event timelines; no DOM, keystroke, or form-value recording |
50
+ | Payment conversion tracking | Trusted server-side ledger with idempotent event IDs and canonical site currency |
51
+ | Goals and funnels | Exact pageview/custom-event goals and ordered conversion funnels |
52
+ | Affiliates | Referral attribution, trusted revenue, and commission accounting |
53
+ | Cross-site analytics | Reports over up to ten authorized site IDs with a preloaded pseudonymous visitor ID |
54
+ | Automatic GeoIP | Opt-in native request metadata plus IPinfo/IPWhois fallback; bounded quota and no raw-IP persistence |
55
+ | Auto-captured clicks | Anchors, buttons, and `data-rastro-event` controls with a bounded visible label; outbound origin+path |
56
+ | Bot filtering | Self-declared crawlers, unfurlers, monitors, HTTP libraries, and headless browsers rejected at ingestion |
57
+ | Cookieless mode | No cookies: persistent pseudonymous visitor id in `localStorage`, per-tab session id in `sessionStorage` |
58
+ | Tiny tracker | 2,132 bytes raw, 1,127 bytes gzip; the test suite enforces `< 1,280` bytes gzip |
59
+
60
+ ## Quickstart
61
+
62
+ ### Prerequisites
63
+
64
+ - Convex `^1.43.0` or a compatible Synapse deployment.
65
+ - A way to authorize site administration and local report functions: the host's
66
+ existing authentication provider, or, for a site with no user accounts, a
67
+ fixed opaque owner ID and internal functions run with deployment credentials
68
+ (see [Hosts without user authentication](#hosts-without-user-authentication)).
69
+ The browser ingestion route itself is public and origin-checked.
70
+ - Two deployment origins recorded separately:
71
+
72
+ | Purpose | Convex Cloud example | Synapse example |
73
+ | ------------------------------ | ------------------------------ | -------------------------------------- |
74
+ | Functions and reactive queries | `https://project.convex.cloud` | `https://project.synapse.example` |
75
+ | HTTP actions and tracker | `https://project.convex.site` | `https://project.site.synapse.example` |
76
+
77
+ Never append `/rastro/tracker.js` to the functions URL.
78
+
79
+ ### 1. Install
30
80
 
31
81
  ```bash
32
- npm install @iann29/rastro
82
+ npm install @iann29/rastro@alpha
33
83
  ```
34
84
 
35
- Mount the component under an HTTP prefix:
85
+ The untagged npm `latest` channel intentionally remains on the first public
86
+ alpha. Use `@alpha` until a stable release is promoted.
87
+
88
+ ### 2. Mount the component
89
+
90
+ Merge the component into the project's existing configuration; preserve every
91
+ component and environment binding already registered there.
36
92
 
37
93
  ```ts
38
94
  // convex/convex.config.ts
@@ -45,14 +101,164 @@ app.use(rastro, { httpPrefix: "/rastro/" });
45
101
  export default app;
46
102
  ```
47
103
 
48
- This creates isolated component tables and three HTTP routes:
104
+ This creates isolated component tables and five HTTP routes:
49
105
 
50
106
  - `POST /rastro/events` — browser event ingestion
51
107
  - `OPTIONS /rastro/events` — CORS preflight
52
108
  - `GET /rastro/tracker.js` — the pre-gzipped tracker
109
+ - `GET /rastro/vitals.js` — the pre-gzipped, opt-in Web Vitals module
53
110
  - `GET /rastro/health` — integration health
54
111
 
55
- ## Create a site
112
+ Push the component and regenerate host bindings before importing
113
+ `components.rastroAnalytics`:
114
+
115
+ ```bash
116
+ # Convex Cloud development deployment
117
+ npx convex dev
118
+
119
+ # Synapse development deployment
120
+ synapse dev --once
121
+ ```
122
+
123
+ For production, use the product's existing release workflow. On an uncoordinated
124
+ Convex project that is `npx convex deploy`; on Synapse use
125
+ `synapse release --execute` when the project has a release manifest, otherwise
126
+ `synapse deploy`. Never deploy before confirming the target.
127
+
128
+ Verify the HTTP origin independently from the functions origin:
129
+
130
+ ```bash
131
+ curl -fsS https://YOUR_HTTP_ORIGIN/rastro/health
132
+ ```
133
+
134
+ A successful health response proves that the component HTTP routes are mounted;
135
+ it does not prove that a browser event was accepted.
136
+
137
+ ### Automatic GeoIP
138
+
139
+ Rastro uses Convex request metadata as the canonical client IP and can resolve
140
+ coarse city-level coordinates on the first request of each session. GeoIP is
141
+ opt-in because the configured provider receives the request IP. Declare the
142
+ settings as optional host environment variables and pass them through, so each
143
+ deployment decides on its own provider, token, and cap without a code change:
144
+
145
+ ```ts
146
+ // convex/convex.config.ts
147
+ import { defineApp } from "convex/server";
148
+ import { v } from "convex/values";
149
+ import rastro from "@iann29/rastro/convex.config.js";
150
+
151
+ const app = defineApp({
152
+ env: {
153
+ RASTRO_GEOIP_PROVIDER: v.optional(
154
+ v.union(v.literal("ipinfo"), v.literal("ipwhois")),
155
+ ),
156
+ RASTRO_GEOIP_TOKEN: v.optional(v.string()),
157
+ RASTRO_GEOIP_DAILY_LIMIT: v.optional(v.string()),
158
+ RASTRO_TRUST_PROXY: v.optional(v.literal("true")),
159
+ },
160
+ });
161
+ app.use(rastro, {
162
+ httpPrefix: "/rastro/",
163
+ env: {
164
+ RASTRO_GEOIP_PROVIDER: app.env.RASTRO_GEOIP_PROVIDER,
165
+ RASTRO_GEOIP_TOKEN: app.env.RASTRO_GEOIP_TOKEN,
166
+ RASTRO_GEOIP_DAILY_LIMIT: app.env.RASTRO_GEOIP_DAILY_LIMIT,
167
+ RASTRO_TRUST_PROXY: app.env.RASTRO_TRUST_PROXY,
168
+ },
169
+ });
170
+
171
+ export default app;
172
+ ```
173
+
174
+ This is the shape of the [example host](example/convex/convex.config.ts). With
175
+ nothing set, provider lookups stay off. To enable IPinfo Core on one deployment:
176
+
177
+ ```bash
178
+ # Convex Cloud development deployment
179
+ npx convex env set --deployment dev RASTRO_GEOIP_PROVIDER ipinfo
180
+ npx convex env set --deployment dev RASTRO_GEOIP_TOKEN 'YOUR_IPINFO_TOKEN'
181
+ npx convex env set --deployment dev RASTRO_GEOIP_DAILY_LIMIT 1000
182
+
183
+ # Synapse development deployment
184
+ synapse convex --dev env set RASTRO_GEOIP_PROVIDER ipinfo
185
+ synapse convex --dev env set RASTRO_GEOIP_TOKEN 'YOUR_IPINFO_TOKEN'
186
+ synapse convex --dev env set RASTRO_GEOIP_DAILY_LIMIT 1000
187
+ ```
188
+
189
+ Binding a literal instead of a passthrough (`RASTRO_GEOIP_PROVIDER: "ipinfo"`)
190
+ is also valid when every deployment of the project shares the provider.
191
+
192
+ `ipwhois` is also supported. Its token is optional for the documented
193
+ 1,000-request/day free endpoint and recommended for production. Bind it as
194
+ `RASTRO_GEOIP_TOKEN` in the same way as the IPinfo example.
195
+ `RASTRO_GEOIP_DAILY_LIMIT` defaults to 1,000 lookups per site per UTC day and
196
+ may be set from `0` through `1000000` to match the provider plan. Admission is
197
+ transactional, so concurrent first-session requests cannot exceed the cap.
198
+
199
+ When Rastro runs behind a reverse proxy that supplies `X-Forwarded-For` or
200
+ Vercel/Cloudflare geographic headers, set `RASTRO_TRUST_PROXY` to `true` on that
201
+ deployment. Leave it unset for direct Convex deployments. Rastro uses forwarded
202
+ data only when this opt-in is enabled and the runtime IP identifies a private
203
+ immediate peer. Caller-supplied proxy or geographic headers are ignored when
204
+ native metadata is absent or public.
205
+
206
+ The lookup has a 1.5-second timeout and fails open: telemetry ingestion
207
+ continues without location when the provider is unavailable. A session reserves
208
+ its single lookup attempt atomically only after its initial event is persisted;
209
+ heartbeats and concurrent duplicate requests do not consume provider quota. The
210
+ raw IP is never passed to a mutation or stored in component tables; only
211
+ sanitized country, city, and coordinates rounded to one decimal place are
212
+ persisted.
213
+
214
+ `GET /rastro/health` reports whether the runtime exposes a usable client IP and
215
+ whether GeoIP is configured and the daily cap, without returning the address or
216
+ token. Deployment administrators can exercise the configured provider through
217
+ the internal `geo:probe` action. If health reports no usable client IP, use
218
+ trusted Vercel/Cloudflare geographic headers with `RASTRO_TRUST_PROXY` or fix
219
+ the deployment's proxy forwarding. Health also names the build the deployment
220
+ serves under `tracker`: the package `version` plus the content `hash` of
221
+ `tracker.js` and the `vitalsHash` of `vitals.js`. Both scripts carry that hash
222
+ as a strong `ETag`, answer `If-None-Match` with `304`, and are cached for five
223
+ minutes with up to one hour of `stale-while-revalidate`, so a returning visitor
224
+ runs a new build after at most one stale page load; an open tab keeps its script
225
+ until it reloads.
226
+
227
+ ### Browser and device classification
228
+
229
+ The tracker sends no client identification. Ingestion derives `browser`, `os`,
230
+ and `device` from headers the browser attaches itself, and persists only those
231
+ three normalized values; the raw `User-Agent` never reaches a mutation or a
232
+ component table.
233
+
234
+ Chromium attaches the low-entropy `Sec-CH-UA`, `Sec-CH-UA-Mobile`, and
235
+ `Sec-CH-UA-Platform` client hints to cross-origin requests from secure origins,
236
+ with no `Accept-CH` opt-in required of the tracked site. They are preferred when
237
+ present, because they are the only signal separating Brave, Opera, Vivaldi,
238
+ Samsung Internet, Yandex, and bare Chromium from Chrome: Brave ships Chrome's
239
+ `User-Agent` verbatim by design. Hint brands are allowlisted, so an unrecognized
240
+ or forged brand falls back to the `User-Agent` instead of entering the browser
241
+ dimension verbatim.
242
+
243
+ The `User-Agent` remains the fallback for Firefox and Safari, which implement no
244
+ client hints, and for insecure origins, which receive none. Known limits of that
245
+ path:
246
+
247
+ - iPadOS 13+ requests sites with a Macintosh `User-Agent` by default and Safari
248
+ sends no hints, so those sessions are reported as desktop macOS.
249
+ - Without hints every Android client is reported as mobile;
250
+ `Sec-CH-UA-Mobile: ?0` on the Android platform is the only available tablet
251
+ signal, because the explicit `Sec-CH-UA-Form-Factors` hint is high entropy and
252
+ would require an `Accept-CH` opt-in and a permissions-policy delegation from
253
+ every tracked site.
254
+ - Known bots are rejected at ingestion by `User-Agent`: self-declared crawlers,
255
+ link unfurlers, uptime monitors, HTTP libraries, headless browsers, and any
256
+ agent carrying a URL. The response keeps the usual counters with `rejected`
257
+ set and adds `X-Rastro-Rejected: bot`; overview metadata reports
258
+ `botDetection` as `userAgent`. A scripted browser that spoofs a stock
259
+ `User-Agent` is still counted as an ordinary session.
260
+
261
+ ### 3. Create a site
56
262
 
57
263
  Components cannot read the host app's authentication state. The host must
58
264
  authenticate the caller and pass an opaque owner ID.
@@ -78,7 +284,7 @@ export const createSite = mutation({
78
284
  if (!identity) throw new Error("Not authenticated");
79
285
 
80
286
  return await analytics.createSite(ctx, {
81
- ownerId: identity.subject,
287
+ ownerId: identity.tokenIdentifier,
82
288
  name: args.name,
83
289
  domains: args.domains,
84
290
  currency: args.currency,
@@ -88,12 +294,90 @@ export const createSite = mutation({
88
294
  });
89
295
  ```
90
296
 
297
+ Call this mutation from the host application's authenticated administration UI
298
+ and persist or display the returned `siteId`:
299
+
300
+ ```tsx
301
+ const createSite = useMutation(api.analyticsAdmin.createSite);
302
+ const siteId = await createSite({
303
+ name: "Amage Product",
304
+ domains: ["product.example.com", "localhost"],
305
+ currency: "BRL",
306
+ });
307
+ ```
308
+
309
+ Do not call the authenticated example with a bare CLI command: without an
310
+ explicit test identity, `ctx.auth.getUserIdentity()` correctly returns `null`.
311
+
91
312
  Allowed domains are checked at ingestion. Exact hosts and wildcard subdomains
92
313
  such as `*.example.com` are supported. `Origin` is an abuse signal, not
93
314
  authentication; Amage Rastro also enforces per-session and fixed-shard per-site
94
315
  event/byte budgets.
95
316
 
96
- ## Add the tracker
317
+ Domain entries are hostnames, not host-and-port pairs. Use `localhost` or a full
318
+ origin such as `http://localhost:3000`; both normalize to the `localhost` host.
319
+ A site deployed on Vercel needs its preview hosts too: list `*.vercel.app` next
320
+ to the production host, or preview deployments are rejected as
321
+ `ORIGIN_NOT_ALLOWED`.
322
+
323
+ #### Hosts without user authentication
324
+
325
+ An institutional site with no user accounts has nobody to authenticate. Use one
326
+ fixed opaque owner ID and internal functions, which only deployment credentials
327
+ can run:
328
+
329
+ ```ts
330
+ // convex/analyticsAdmin.ts
331
+ import { v } from "convex/values";
332
+ import { Rastro } from "@iann29/rastro";
333
+ import { components } from "./_generated/api";
334
+ import { internalMutation } from "./_generated/server";
335
+
336
+ const analytics = new Rastro(components.rastroAnalytics);
337
+
338
+ // Opaque and stable. Reuse the same value as `ownerId` when provisioning a
339
+ // federation grant.
340
+ const SITE_OWNER_ID = "acme-site-owner";
341
+
342
+ export const createSite = internalMutation({
343
+ args: {
344
+ name: v.string(),
345
+ domains: v.array(v.string()),
346
+ currency: v.string(),
347
+ },
348
+ returns: v.string(),
349
+ handler: async (ctx, args) =>
350
+ await analytics.createSite(ctx, {
351
+ ownerId: SITE_OWNER_ID,
352
+ name: args.name,
353
+ domains: args.domains,
354
+ currency: args.currency,
355
+ cookieless: true,
356
+ }),
357
+ });
358
+ ```
359
+
360
+ Run it once per site with the deployment's own credentials, never from a
361
+ browser:
362
+
363
+ ```bash
364
+ # Convex Cloud development deployment
365
+ npx convex run --deployment dev analyticsAdmin:createSite \
366
+ '{"name":"Acme","domains":["acme.example","*.vercel.app"],"currency":"BRL"}'
367
+
368
+ # Synapse development deployment
369
+ synapse convex --dev run analyticsAdmin:createSite \
370
+ '{"name":"Acme","domains":["acme.example","*.vercel.app"],"currency":"BRL"}'
371
+ ```
372
+
373
+ Such a host needs no local report functions: the central dashboard reads the
374
+ [federated surface](#federated-dashboard-backend), and its `auth.config.ts`
375
+ lists only the Rastro federation provider, exactly as the
376
+ [example host](example/convex/auth.config.ts) does. Goals, funnels, affiliates,
377
+ and retention policies are configured the same way, through `internalMutation`s
378
+ that call the `Rastro` class with `SITE_OWNER_ID`.
379
+
380
+ ### 4. Add the tracker
97
381
 
98
382
  Use the HTTP-actions URL for your deployment, not the Convex function URL.
99
383
 
@@ -123,8 +407,61 @@ serves the script and ingestion from different origins:
123
407
  ></script>
124
408
  ```
125
409
 
410
+ In a Next.js App Router layout, `next/script` injects the same tag;
411
+ `document.currentScript` is set when it runs, so `data-site` and the inferred
412
+ endpoint work exactly as with a plain tag:
413
+
414
+ ```tsx
415
+ // app/layout.tsx
416
+ import Script from "next/script";
417
+
418
+ export default function RootLayout({ children }) {
419
+ return (
420
+ <html lang="pt-BR">
421
+ <body>
422
+ {children}
423
+ <Script
424
+ src="https://YOUR_DEPLOYMENT.convex.site/rastro/tracker.js"
425
+ data-site="YOUR_SITE_ID"
426
+ strategy="afterInteractive"
427
+ />
428
+ </body>
429
+ </html>
430
+ );
431
+ }
432
+ ```
433
+
434
+ Remember the site's `domains`: a Vercel preview runs on `*.vercel.app`, not on
435
+ the production host.
436
+
437
+ ### Field Web Vitals (optional)
438
+
439
+ A second, separate module measures real-visitor LCP, CLS, INP, FCP, and TTFB
440
+ with `PerformanceObserver` and reports them once per page load, when the tab is
441
+ first hidden. It stays out of `tracker.js` so the base tracker keeps its byte
442
+ budget; add it only where you want performance data:
443
+
444
+ ```html
445
+ <script
446
+ defer
447
+ data-site="YOUR_SITE_ID"
448
+ src="https://YOUR_DEPLOYMENT.convex.site/rastro/vitals.js"
449
+ ></script>
450
+ ```
451
+
452
+ The module shares the tracker's session identity through `sessionStorage`,
453
+ accepts the same `data-endpoint` and `data-visitor` overrides, and works with or
454
+ without the base tracker on the page. Measurements are attributed to the landing
455
+ path of the page load, never to later SPA routes. Vitals feed only the
456
+ `vitalsReport` histograms — analytics event counts, sessions, and live presence
457
+ do not change. Browsers without an entry type simply skip that metric (Safari
458
+ reports no LCP, CLS, or INP).
459
+
126
460
  ### React
127
461
 
462
+ React is an optional peer dependency. It is required only when importing the
463
+ `@iann29/rastro/react` entry point; backend-only consumers do not need it.
464
+
128
465
  ```tsx
129
466
  import { useState } from "react";
130
467
  import { RastroScript, useRastro } from "@iann29/rastro/react";
@@ -154,6 +491,34 @@ the same value on authorized sites to connect their journeys. Amage Rastro
154
491
  intentionally has no mutable post-load `identify` command: changing identity
155
492
  after a session starts would make the session ambiguous.
156
493
 
494
+ ### 5. Verify ingestion
495
+
496
+ 1. Open an allowed site origin in a fresh browser tab.
497
+ 2. In browser developer tools, filter Network requests by `rastro/events`.
498
+ 3. Wait for the normal tracker flush, which is debounced by approximately 1.2
499
+ seconds.
500
+ 4. Confirm `POST /rastro/events` returns HTTP 200 and a response containing
501
+ `accepted`, `duplicates`, and `rejected` counters.
502
+ 5. Run `analytics:liveVisitors` (with `siteIds` and `now`) or
503
+ `analytics:sessionJourney` for the returned `siteId`: both reflect the
504
+ request as soon as it is accepted. `analytics:listSessions` and the
505
+ aggregate-backed `analytics:overview` can trail the live view by a few
506
+ seconds, and the overview only reports complete UTC hour/day buckets, so an
507
+ empty result from those two right after the first request is not a failed
508
+ ingestion.
509
+
510
+ The tracker intentionally does not interrupt the product UI when transport
511
+ fails. A loaded `tracker.js` or successful `/health` response alone is not proof
512
+ of ingestion. The usual causes of a rejected or missing request are:
513
+
514
+ - using the functions URL instead of the HTTP-actions URL;
515
+ - omitting the exact browser host from the site's `domains`, including
516
+ `*.vercel.app` for preview deployments;
517
+ - including query strings or paths in a configured base URL;
518
+ - querying a partial UTC bucket or a site the authenticated user does not own;
519
+ - reading `listSessions` or `overview` seconds after the first request instead
520
+ of `liveVisitors`.
521
+
157
522
  ## Track custom events
158
523
 
159
524
  ```js
@@ -168,6 +533,12 @@ For declarative click capture:
168
533
  </button>
169
534
  ```
170
535
 
536
+ Auto-captured clicks record the control's visible text, bounded to 64 characters
537
+ and whitespace-normalized, so journeys read "Clicked «View pricing»" without
538
+ annotation. `data-rastro-label` replaces that text; use it on controls whose
539
+ copy may contain personal data. Input values are never read. Outbound links keep
540
+ their origin and pathname; internal links keep the pathname.
541
+
171
542
  Properties accept bounded string, number, boolean, or null values. Never send
172
543
  emails, names, form values, secrets, or other personal data as analytics
173
544
  properties.
@@ -178,6 +549,47 @@ session or with a timestamp in another minute is allowed. The tracker preserves
178
549
  timestamps when retrying. Trusted payment event IDs remain separately
179
550
  payload-bound by the site's financial ledger.
180
551
 
552
+ ## Attribute campaigns
553
+
554
+ The tracker reads `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, and
555
+ `utm_content` from the landing URL, keeps them in `sessionStorage` for the rest
556
+ of the session, and sends them as properties of every pageview; no other query
557
+ parameter ever leaves the page. A session's `source` is the lowercase
558
+ `utm_source` when present and the referrer host otherwise, so campaign traffic
559
+ no longer reads as `direct`. `utmSource`, `utmMedium`, and `utmCampaign` stay on
560
+ the session for journeys, and `overview.topCampaigns` ranks `utm_campaign` by
561
+ event volume next to `topSources`.
562
+
563
+ ## Identify visitors after signup
564
+
565
+ Anonymous sessions carry a random `visitorId` until the host supplies a
566
+ pseudonymous one through `data-visitor` or the `RastroScript` `visitorId` prop.
567
+ To keep the anonymous history when a visitor creates an account or signs in,
568
+ link the two ids from the trusted server path, never from the browser:
569
+
570
+ ```js
571
+ const { visitorId: anonymousVisitorId } = window.rastro("context");
572
+ // Send anonymousVisitorId with the signup or sign-in request.
573
+ ```
574
+
575
+ ```ts
576
+ await analytics.linkVisitor(ctx, {
577
+ siteId,
578
+ visitorId: user.analyticsId, // the value later passed to the tracker
579
+ previousVisitorId: anonymousVisitorId,
580
+ });
581
+ ```
582
+
583
+ `visitorJourney` for either id then returns one chronological journey; every
584
+ event keeps the id it was recorded with. Links are idempotent, an anonymous id
585
+ belongs to at most one identity, a linked id cannot be linked again, chains and
586
+ identity merges are rejected, and an identity holds at most ten aliases. The
587
+ tracker still sets no cookie: the host's own session identifies the account and
588
+ the anonymous ids live in the browser's own storage (the visitor id in
589
+ `localStorage`, the session id in `sessionStorage`). Linking anonymous history
590
+ to an account turns it into personal data under the host's privacy policy;
591
+ disclose it and offer an opt-out.
592
+
181
593
  ## Attribute trusted payments
182
594
 
183
595
  Browser-created conversion events are untrusted telemetry and never affect
@@ -239,6 +651,7 @@ export const {
239
651
  goalsReport,
240
652
  funnelsReport,
241
653
  affiliatesReport,
654
+ siteMap,
242
655
  } = exposeAnalyticsApi(components.rastroAnalytics, {
243
656
  authorize: async (ctx, request) => {
244
657
  const identity = await ctx.auth.getUserIdentity();
@@ -246,12 +659,12 @@ export const {
246
659
 
247
660
  for (const siteId of request.siteIds) {
248
661
  const site = await analytics.getSite(ctx, siteId);
249
- if (!site || site.ownerId !== identity.subject) {
662
+ if (!site || site.ownerId !== identity.tokenIdentifier) {
250
663
  throw new Error("Not authorized for analytics site");
251
664
  }
252
665
  }
253
666
 
254
- if (request.ownerId && request.ownerId !== identity.subject) {
667
+ if (request.ownerId && request.ownerId !== identity.tokenIdentifier) {
255
668
  throw new Error("Not authorized for analytics owner");
256
669
  }
257
670
  },
@@ -262,45 +675,171 @@ Do not copy the unauthenticated demo boundary into production. The included demo
262
675
  exposes only fixed-scope read reports plus bounded, idempotent seed/live-refresh
263
676
  functions.
264
677
 
678
+ ### Federated dashboard backend
679
+
680
+ The reference control plane in `control-plane/` can connect a central dashboard
681
+ while telemetry remains in the customer's deployment. It uses
682
+ `exposeFederatedAnalyticsApi` with issuer and connection-resolver options. The
683
+ helper constructs its authorization boundary internally; it cannot accept a
684
+ permissive host authorizer. Its surface contains a public protocol v1 manifest,
685
+ authenticated redacted connection status, redacted site summaries, the reactive
686
+ dashboard report queries, and — behind the optional `configure` capability — the
687
+ functions that manage goals, funnels, affiliates, a site's name, domains and
688
+ timezone, and the retention policy from the dashboard.
689
+
690
+ The JWT only identifies the requested connection. Site access remains
691
+ authoritative in a local host record resolved on every query, so revocation is
692
+ immediate and a token cannot grant itself another site. The same record decides
693
+ whether the dashboard may configure: a configure function runs only when the
694
+ token carries `analytics:configure` (the control plane claims it for
695
+ organization owners and admins alone) and the host's grant lists it too;
696
+ otherwise the dashboard shows the same forms but hands over the code the host
697
+ runs itself. `listSites` accepts no site arguments and omits owner, domain, and
698
+ network configuration. Stable manifest names, capabilities, limits, and
699
+ authorization error codes are exported for connector clients. The host must
700
+ already have Convex authentication and a local authoritative grant. Follow
701
+ [`docs/federation-setup.md`](docs/federation-setup.md) for the executable setup
702
+ and verification sequence; use [`docs/federation.md`](docs/federation.md) as the
703
+ protocol reference. The production control plane is available at
704
+ `https://www.amagerastro.com`; before connecting a customer deployment, verify
705
+ the selected package exports the federation helper as shown in the setup
706
+ runbook.
707
+
265
708
  ## Backend API
266
709
 
267
710
  The `Rastro` class wraps the component boundary for host functions:
268
711
 
269
712
  - Sites: `createSite`, `updateSite`, `getSite`, `listSites`
270
- - Ingestion: `ingest`, `trackConversion`
713
+ - Ingestion: `ingest`, `trackConversion`, `linkVisitor`
271
714
  - Reports: `overview`, `liveVisitors`, `listSessions`, `sessionJourney`,
272
715
  `listConversions`, `visitorJourney`, `goalsReport`, `funnelsReport`,
273
- `affiliatesReport`
716
+ `affiliatesReport`, `vitalsReport`, `siteMap`, `dataCoverage`
274
717
  - Configuration: `upsertGoal`, `listGoals`, `removeGoal`, `upsertFunnel`,
275
718
  `listFunnels`, `removeFunnel`, `upsertAffiliate`, `listAffiliates`,
276
719
  `removeAffiliate`
277
- - Retention: `cleanup`
720
+ - Retention: `cleanup`, `setRetentionPolicy`, `disableRetentionPolicy`,
721
+ `runRetentionPolicy`, `retentionStatus`
278
722
 
279
723
  Every public function has argument and return validators. Growing reads are
280
724
  indexed and bounded. Component pagination uses scope-bound opaque keyset cursors
281
725
  because native `.paginate()` is not supported inside Convex components; page
282
726
  cost stays constant instead of increasing with an offset.
283
727
 
728
+ `siteMap` reads the daily route rollups newest day first and returns per-route
729
+ totals — pageviews, estimated visitors, entries, derived exits and bounces
730
+ (`exits = entries + transitions in − transitions out`,
731
+ `bounces = entries − sessions that continued`), and time on page — plus
732
+ route-to-route transition counts and a per-route daily series. Numeric, UUID,
733
+ and long hexadecimal path segments collapse to `:id` in the map only; the
734
+ package exports `normalizeRoute` so dashboards bucket live visitors by the same
735
+ rule, and `liveVisitors` reports `previousPath` and `movedAt` when a visitor
736
+ moves between routes. Map coverage begins at the first ingest after the upgrade
737
+ that introduced the rollups.
738
+
739
+ Overview responses label visitor values as either HLL-estimated unique visitors
740
+ or legacy `bucketOccurrences`; legacy aggregate rows are never presented as
741
+ exact uniques. Anonymous visitors carry a persistent pseudonymous id from the
742
+ tracker's `localStorage`, so the same person counts once across tabs, days, and
743
+ returns. When storage is blocked or an old cached tracker reports no id,
744
+ sessions from the same device and network within one UTC day still fold into one
745
+ visitor through a salted daily key; runtimes without a client IP fall back to
746
+ one visitor per session, which `/rastro/health` reports as
747
+ `uniqueVisitors: "session"` instead of `"dailyHash"`. Breakdown metadata says
748
+ what each ranking counts: pages by pageviews and sources, campaigns, countries,
749
+ devices, and browsers by sessions once every bucket in the range carries those
750
+ counters (recorded since alpha.11), and raw event volume for ranges that reach
751
+ back before them; events always rank by volume. Totals carry the engagement
752
+ counters recorded since the same stamp — summed session duration, sessions that
753
+ reached a second pageview, and first-ever sessions of a visitor — and
754
+ `metadata.engagement` reports `counted` when the whole range has them, so bounce
755
+ rate, average duration, pages per session, and the share of new visitors derive
756
+ from one response; `unavailable` means the range reaches back before the
757
+ counters and those four stay blank. Feature status is explicit: outbound-link
758
+ event volume is supported, bot detection rejects self-declared agents by
759
+ `User-Agent`, and session replay and error insights are currently unsupported.
760
+
761
+ All report ranges use integer Unix milliseconds with inclusive `from` and `to`
762
+ boundaries, and range metadata reports `boundary: "inclusive"`. Hourly overview
763
+ queries must contain complete UTC hours. A daily overview query must contain
764
+ either complete UTC days or, when every requested site has the same timezone,
765
+ complete calendar days in that zone: a site with a timezone gets one aggregate
766
+ bucket per local calendar day at ingestion, next to the UTC hour and day
767
+ buckets, so "yesterday" in São Paulo ends at midnight in São Paulo. Range
768
+ metadata then reports `timezone` as the zone the buckets follow, `"UTC"`
769
+ otherwise. Local-day coverage starts at the site's first ingest after the
770
+ upgrade (`analyticsCoverage.localDaySince`) and restarts when its timezone
771
+ changes; a range reaching further back is reported as partial coverage, and
772
+ `sites.localDays` (federated `listSites.localDays`) tells a dashboard the zone
773
+ and the stamp up front. A zone that never leaves UTC keeps no extra buckets.
774
+ Funnel and affiliate reports must contain complete UTC days. Those
775
+ aggregate-backed reports fail with `REPORT_INCOMPLETE` instead of silently
776
+ including the rest of a partial bucket. Goal reports retain exact millisecond
777
+ filtering while bounded goal-completion rows are available.
778
+
779
+ Goal, funnel, and affiliate reports use definition-ID keyed daily rollups when
780
+ the low-churn rollout control proves that generation is available. Each loader
781
+ enforces day, row, document, and byte headroom before returning a complete
782
+ result; it never returns a silently truncated report. Ordinary ingestion never
783
+ patches or schedules work against a shared coverage document. `dataCoverage`
784
+ derives availability through bounded indexed reads of source and rollup records,
785
+ while the control document stores only rollout generation and durable retention
786
+ watermarks. Heartbeats can advance session availability but do not claim event
787
+ or aggregate availability. Hourly and daily overview availability are reported
788
+ separately as `overviewHour` and `overviewDay` because their retention
789
+ boundaries use different bucket alignment.
790
+
284
791
  ## Operational limits
285
792
 
286
793
  - HTTP body: 64 KiB
287
794
  - Events per batch: 50
288
795
  - Session-minute groups per batch: 4
289
796
  - Active goals per exact matcher: 1
797
+ - Web Vitals pages per site and UTC day: 48 tracked page rows, then `(other)`;
798
+ device classes are fixed (desktop, mobile, tablet, unknown)
799
+ - Web Vitals report: complete reads bounded to 5,000 rollup rows across at most
800
+ 90 UTC days; wider saturated ranges fail with `REPORT_INCOMPLETE`
801
+ - Site map routes per site-day shard: 200 tracked route rows, then `(other)`; 24
802
+ outgoing-transition slots per route row
803
+ - Site map report: reads newest day first, bounded to 8,000 rollup rows across
804
+ at most 90 UTC days; older days beyond the budget are declared as partial
805
+ coverage instead of failing the report
290
806
  - Primary aggregate shards: 256 hourly and 128 daily, derived from stable
291
807
  session identity
292
808
  - Funnel and affiliate aggregate shards: 16
293
- - Site ingress budget: 4,096 deterministic per-minute shards, each capped at
294
- 300 events and 512 KiB (1,228,800 admitted events/minute at uniform load)
809
+ - Report rollups (goal, funnel, affiliate, route, and Web Vitals daily rows) are
810
+ never written by the ingestion transaction: a batch appends one document to
811
+ the site's rollup queue and one leased fold job per site merges up to 200
812
+ queued batches per run into the rows, so every rollup row has a single writer
813
+ and concurrent ingestion cannot conflict on it. Reports see a batch's rollups
814
+ a few hundred milliseconds after ingestion accepts it; a fold chain that dies
815
+ is restarted by the next batch once its 60-second lease lapses
816
+ - Site ingress budget: 4,096 deterministic per-minute shards, each capped at 300
817
+ events and 512 KiB (1,228,800 admitted events/minute at uniform load)
295
818
  - Rate-window storage: one reusable row per active session and site shard
296
- - Live session timeout: 60 seconds with one bounded five-second expiry sweep per
297
- site; legacy per-session jobs hand off to that sweep during the cutover
819
+ - Live session timeout: a `leave` beacon on `pagehide` retires the visitor about
820
+ ten seconds after they leave and a back/forward-cache return revives them
821
+ immediately; the safety net for a browser that never says goodbye is 90
822
+ seconds since the last event or heartbeat (heartbeats fire every 20 seconds,
823
+ within background-tab throttling), with one bounded five-second expiry sweep
824
+ per site; legacy per-session jobs hand off to that sweep during the cutover
825
+ - Clock alignment: the tracker and the vitals module stamp each batch with
826
+ `sentAt`; when that clock is more than 30 seconds from the server's, every
827
+ event in the batch is re-anchored by the offset before ingestion, so a visitor
828
+ whose device clock is wrong still goes live and is filed under the right hour
298
829
  - Dashboard overview: up to ten sites and bounded report ranges
299
830
  - Hourly overview: up to 24 hours; wider ranges use daily buckets
300
- - Retention cleanup: bounded batches; schedule host maintenance for the data
301
- classes your policy retains. The `events` cleanup kind covers both legacy
302
- event rows and new event batches and processes at most 32 heavy batches per
303
- transaction.
831
+ - Retention cleanup: bounded batches. `setRetentionPolicy` automatically starts
832
+ leased, persisted cleanup chains for every policy-managed dataset; interrupted
833
+ attempts resume after lease expiry and stale generations cannot mutate the
834
+ active chain. A completed chain schedules its next cycle after one day and
835
+ recomputes its cutoff from the then-current policy. Policy updates replace the
836
+ generation, and `disableRetentionPolicy` makes every queued attempt stale.
837
+ `runRetentionPolicy` remains available for an explicit bounded run. The
838
+ `events` cleanup kind removes canonical event-batch documents and processes at
839
+ most 32 heavy batches per transaction.
840
+ - Retention cutoffs are bucket-safe: event minutes, hourly aggregates, daily
841
+ aggregates and feature rollups preserve the bucket containing a cutoff and
842
+ return the exact effective `before` boundary they applied.
304
843
 
305
844
  Capacity claims use declared workload profiles. Complete release evidence
306
845
  reports ten-minute `lean`, `realistic`, `heavy`, and browser `feature` profiles
@@ -308,12 +847,64 @@ separately. The browser `feature` profile exercises configured goals, funnels,
308
847
  and affiliate attribution; trusted conversions require a separate server-side
309
848
  driver and certification result.
310
849
 
311
- The historical development evidence and its certification caveats are recorded
312
- in [`docs/benchmarks/2026-08-20-realistic.md`](docs/benchmarks/2026-08-20-realistic.md).
850
+ ### Certified ingest evidence (2026-08-30)
851
+
852
+ | Profile | Workload | Certified stored/day | p95 latency | 100M/day target |
853
+ | --------- | ------------------------------------------------- | -------------------: | ----------: | --------------- |
854
+ | Lean | Minimal events, maximum batches | 178.95M | 4,167 ms | Pass |
855
+ | Realistic | Reused sessions, browser mix, context, heartbeats | 142.04M | 4,953 ms | Pass |
856
+ | Heavy | 62 KiB requests with bounded custom properties | 55.26M | 7,702 ms | Below target |
857
+ | Feature | Goals, funnels, and affiliate attribution | **failed** | — | Defect [#52] |
858
+
859
+ [#52]: https://github.com/amageweb/amage-rastro/issues/52
860
+
861
+ Lean, realistic, and heavy each ran at least ten minutes, completed with zero
862
+ request failures, duplicates, or rejected events, and exactly reconciled the
863
+ expected stored events (the realistic run's reconciliation was re-executed
864
+ exactly after a client-side DNS failure — see the report). The realistic row's
865
+ driver session pool is 4,096: at current speed the historical 2,048-session pool
866
+ pushes single sessions past the documented 120 events/minute budget. Results
867
+ came from one client process against the same non-production Synapse DEV
868
+ deployment and site as the 2026-08-21 certification; they are engineering
869
+ evidence, not a production SLA.
870
+
871
+ Two caveats stand deliberately. The heavy row measured a store already holding
872
+ the battery's own multi-million-event history; the 2026-08-22 fresh-store result
873
+ (76.62M/day) remains valid for that state, and neither number overwrites the
874
+ other. The feature profile is not certified: on alpha.6, affiliate daily-rollup
875
+ contention lost ~4–5% of batches under concurrent load
876
+ ([#52](https://github.com/amageweb/amage-rastro/issues/52)). Since alpha.11
877
+ ingestion no longer writes rollup rows at all (see the operational limits), but
878
+ no feature-profile capacity is claimed until a new battery certifies it. GeoIP
879
+ with a provider enabled, trusted server-side conversions, and vitals-bearing
880
+ traffic remain separate pending certifications.
881
+
882
+ The full evidence is recorded in
883
+ [`docs/benchmarks/2026-08-30-alpha6-recertification.md`](docs/benchmarks/2026-08-30-alpha6-recertification.md).
884
+ Prior results remain in
885
+ [`docs/benchmarks/2026-08-21-formal-certification.md`](docs/benchmarks/2026-08-21-formal-certification.md)
886
+ and
887
+ [`docs/benchmarks/2026-08-20-realistic.md`](docs/benchmarks/2026-08-20-realistic.md).
313
888
 
314
889
  From a repository checkout, run a duration-based profile against a confirmed
315
890
  non-production deployment and a dedicated idle benchmark site:
316
891
 
892
+ For a deployment-free CI smoke benchmark of write-heavy ingestion plus reactive
893
+ overview and feature report queries, run:
894
+
895
+ ```bash
896
+ npm run benchmark:local
897
+ ```
898
+
899
+ The local harness uses `convex-test`, enforces a 12-document ingest transaction
900
+ budget, checks reactive totals before and after a write, and queries proven
901
+ daily rollups with the maximum configured 50 goals, 20 funnels, and 100
902
+ affiliates under deterministic 1,000-document and 4 MiB read ceilings. It also
903
+ drives concurrent ingest calls and asserts that their shared rollout/retention
904
+ control document is unchanged. It fails when its generous CI latency budgets are
905
+ exceeded. This is a deterministic local regression guard, not evidence of live
906
+ deployment throughput or capacity.
907
+
317
908
  ```bash
318
909
  npm run --silent benchmark:ingest -- \
319
910
  --url https://example.site/rastro/events \
@@ -322,7 +913,8 @@ npm run --silent benchmark:ingest -- \
322
913
  --origin https://your-allowed-origin.example \
323
914
  --profile realistic \
324
915
  --duration 600 \
325
- --concurrency 100 \
916
+ --concurrency 75 \
917
+ --batch-size 50 \
326
918
  --certify
327
919
  ```
328
920
 
@@ -332,49 +924,74 @@ visible in Live Atlas. Only `feature` intentionally matches the demo goal,
332
924
  funnel, and affiliate definitions; it does not call the trusted conversion API.
333
925
  Use `--dry-run` to inspect generated request size without sending traffic. When
334
926
  `--convex-url` is supplied, it must identify the same deployment as `--url` and
335
- expose this repository's `example:overview` query. The final reconciliation is
336
- a site-wide before/after delta, so unrelated traffic on that site invalidates
337
- the result. Reports distinguish accepted ingress, heartbeats, and stored events.
338
- Shorter completed runs report extrapolated daily estimates only. `--certify`
339
- requires at least ten minutes and successful reconciliation before emitting
340
- certified daily capacity fields or exiting successfully as a certification.
927
+ expose this repository's `benchmark:reconcileSessionsPage` query. Reconciliation
928
+ generates exact session IDs from a cryptographic run UUID and reads them through
929
+ bounded index lookups, so unrelated traffic cannot alter the result. The query
930
+ uses the session counters persisted transactionally with event batches; it
931
+ certifies ingest accounting, not raw-event availability after later retention.
932
+ Reports distinguish accepted ingress, heartbeats, and stored events. Shorter
933
+ completed runs report extrapolated daily estimates only. `--certify` requires at
934
+ least ten minutes and successful reconciliation before emitting certified daily
935
+ capacity fields or exiting successfully as a certification.
341
936
 
342
937
  The ingestion transaction folds metric/dimension deltas, session rate counters,
343
938
  site-shard counters, session/live state, visitor attribution, and exact funnel
344
939
  progress before writing. Accepted non-heartbeat telemetry from one request is
345
940
  grouped into one immutable raw document per session and event-minute, without
346
941
  duplicating session geo or user-agent context. A 50-event same-session,
347
- same-minute baseline stays within 10 document writes. Each touched aggregate
348
- bucket is written at most once per batch, and dimension overflow folds into an
349
- explicit `(other)` bucket instead of relabeling old counts.
942
+ same-minute baseline stays within 10 document writes plus the rollup queue entry
943
+ and its fold lease. Each touched aggregate bucket is written at most once per
944
+ batch, and dimension overflow folds into an explicit `(other)` bucket instead of
945
+ relabeling old counts. Goal, funnel, affiliate, route, and Web Vitals rollup
946
+ rows are written only by the site's fold job, never by ingestion.
350
947
  Click/outbound/browser-conversion telemetry skips goal and funnel work that
351
948
  cannot match those event types.
352
949
 
353
- This is an additive storage migration: existing `events` rows are not deleted or
354
- backfilled. Journey and trusted-conversion reads merge legacy rows with new
355
- `eventBatches` rows. Journey `_id` values are stable strings for both formats,
356
- and session-journey cursors use timestamp, creation time, and ID so pagination
357
- matches both storage indexes. The compatibility logic is isolated in
358
- `src/component/eventStore.ts`; ingestion, reports, and retention do not branch
359
- on physical telemetry storage.
950
+ Raw telemetry is stored only in immutable `eventBatches`. Migrated events retain
951
+ their original per-event browser and geo context inside those batches, while new
952
+ events hydrate context from their session. Journey `_id` values are stable
953
+ strings derived from batch and nested-event identity, and session-journey
954
+ cursors use timestamp, creation time, and ID.
955
+
956
+ Existing deployments that still contain the retired `events` table must not
957
+ upgrade directly to this cutover. Follow the exact intermediate release and
958
+ verification sequence in [`docs/upgrading.md`](docs/upgrading.md). Fresh
959
+ deployments need no legacy-event migration.
360
960
 
361
961
  ## Privacy contract
362
962
 
363
963
  Amage Rastro's default tracker:
364
964
 
365
965
  - sets no cookies;
366
- - stores a pseudonymous session ID and referral slug in `sessionStorage`;
367
- - does not collect or persist raw visitor IP addresses;
368
- - stores coarse location only when the host edge provides it;
369
- - does not record the DOM, screenshots, keystrokes, form values, or arbitrary
370
- page text;
371
- - captures only bounded paths, explicit properties, button/anchor targets, and
372
- outbound pathnames;
966
+ - stores a pseudonymous session ID and referral slug in `sessionStorage`, and a
967
+ persistent pseudonymous visitor ID in `localStorage` scoped to the site's
968
+ origin, never shared across sites, gone when the visitor clears site data;
969
+ - never persists raw visitor IP addresses in component tables;
970
+ - counts unique visitors by that persistent pseudonymous ID; when storage is
971
+ blocked or the id is absent, a salted hash of the request IP and `User-Agent`
972
+ that rotates every UTC day takes over, derived in the HTTP action from a
973
+ per-deployment secret; a host-supplied `visitorId` always takes precedence,
974
+ and the raw IP never reaches a mutation or a table;
975
+ - never persists the raw `User-Agent`, only a normalized browser, operating
976
+ system, and device;
977
+ - processes an IP only when an edge supplies geography or an operator enables a
978
+ GeoIP provider, which receives that IP under its own privacy terms;
979
+ - stores only country, city, and coordinates rounded to one decimal place;
980
+ - does not record the DOM, screenshots, keystrokes, form values, input contents,
981
+ or arbitrary page text;
982
+ - captures only bounded paths, explicit properties, the visible label of a
983
+ clicked link or button (at most 64 characters, replaced by `data-rastro-label`
984
+ when present), and outbound link origins and pathnames;
373
985
  - strips query strings and fragments from stored paths and links.
374
986
 
375
- Cookieless does not automatically mean consent-exempt in every jurisdiction.
376
- Review your collection purpose, custom properties, identity strategy, retention
377
- policy, and local law.
987
+ The optional Web Vitals module inherits this contract: it reports at most five
988
+ timing numbers per page load (milliseconds, or CLS scaled by 1000) against the
989
+ landing path, and nothing else.
990
+
991
+ Cookieless does not automatically mean consent-exempt in every jurisdiction, and
992
+ the persistent visitor ID is an identifier under most privacy laws. Review your
993
+ collection purpose, custom properties, identity strategy, retention policy, and
994
+ local law.
378
995
 
379
996
  The example Live Atlas uses MapLibre with OpenFreeMap Positron. Map tiles are
380
997
  requested by the dashboard viewer; Amage Rastro visitor/session data is rendered
@@ -392,6 +1009,10 @@ synapse dev --once
392
1009
  npm run dev:frontend
393
1010
  ```
394
1011
 
1012
+ The root `convex.json` targets the control plane in `control-plane/convex`. The
1013
+ reference consumer host in `example/` is a separate Convex app; see
1014
+ [`example/README.md`](example/README.md) to run, seed, and pair it.
1015
+
395
1016
  Quality gates:
396
1017
 
397
1018
  ```bash
@@ -399,20 +1020,23 @@ npm test
399
1020
  npm run typecheck
400
1021
  npm run lint
401
1022
  npm run build
402
- cd example && npx vite build
1023
+ npm exec vite -- build example
403
1024
  ```
404
1025
 
405
1026
  The test suite covers origin enforcement, event/privacy sanitization,
406
1027
  idempotency, reusable session/site rate limits, bounded live expiry chains,
407
1028
  folded aggregate writes, goals, funnels, trusted payments, currency safety,
408
1029
  affiliate commission, cross-site journeys, keyset pagination beyond 1,000 rows,
409
- retention, HTTP routes, React integration, and tracker size/behavior.
1030
+ retention, request metadata and GeoIP, exact byte boundaries, HTTP routes, React
1031
+ integration, and tracker size/behavior.
410
1032
 
411
- ## Synapse demo
1033
+ ## Synapse deployments
412
1034
 
413
- The active private development project is `ianProjects / Rastro Analytics`. The
414
- public-facing site URL is resolved from `.env.local`; credentials and
415
- `.synapse/` are gitignored.
1035
+ The repository root is linked to the private Synapse project whose DEV and PROD
1036
+ deployments run the control plane in `control-plane/`. The public-facing site
1037
+ URL is resolved from `.env.local`; credentials and `.synapse/` are gitignored.
1038
+ The consumer host in `example/` is a separate Convex app with its own deployment
1039
+ link; run its CLI commands from that directory.
416
1040
 
417
1041
  ## License
418
1042