@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.
- package/README.md +693 -69
- package/agent/integration.md +801 -0
- package/agent/manifest.json +205 -0
- package/agent/manifest.schema.json +444 -0
- package/dist/client/federation.d.ts +381 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +274 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +2673 -15
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +565 -33
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +22 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +315 -4
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +4 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/affiliates.d.ts.map +1 -1
- package/dist/component/affiliates.js +6 -2
- package/dist/component/affiliates.js.map +1 -1
- package/dist/component/cardinality.d.ts +12 -0
- package/dist/component/cardinality.d.ts.map +1 -0
- package/dist/component/cardinality.js +94 -0
- package/dist/component/cardinality.js.map +1 -0
- package/dist/component/constants.d.ts +28 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +44 -1
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +6 -1
- package/dist/component/convex.config.js +9 -1
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/coverage.d.ts +27 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +56 -0
- package/dist/component/coverage.js.map +1 -0
- package/dist/component/diagnostics.d.ts +9 -0
- package/dist/component/diagnostics.d.ts.map +1 -0
- package/dist/component/diagnostics.js +47 -0
- package/dist/component/diagnostics.js.map +1 -0
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/eventStore.d.ts +21 -9
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +142 -152
- package/dist/component/eventStore.js.map +1 -1
- package/dist/component/funnels.d.ts.map +1 -1
- package/dist/component/funnels.js +5 -3
- package/dist/component/funnels.js.map +1 -1
- package/dist/component/geo.d.ts +73 -0
- package/dist/component/geo.d.ts.map +1 -0
- package/dist/component/geo.js +648 -0
- package/dist/component/geo.js.map +1 -0
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +8 -5
- package/dist/component/goals.js.map +1 -1
- package/dist/component/guards.d.ts.map +1 -1
- package/dist/component/guards.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +281 -62
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +13 -0
- package/dist/component/identity.d.ts.map +1 -0
- package/dist/component/identity.js +58 -0
- package/dist/component/identity.js.map +1 -0
- package/dist/component/ingest.d.ts +3 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +498 -95
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +7 -6
- package/dist/component/live.js.map +1 -1
- package/dist/component/localTime.d.ts +25 -0
- package/dist/component/localTime.d.ts.map +1 -0
- package/dist/component/localTime.js +126 -0
- package/dist/component/localTime.js.map +1 -0
- package/dist/component/reports.d.ts +266 -10
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1243 -137
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +75 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +561 -38
- package/dist/component/retention.js.map +1 -1
- package/dist/component/rollupStore.d.ts +320 -0
- package/dist/component/rollupStore.d.ts.map +1 -0
- package/dist/component/rollupStore.js +596 -0
- package/dist/component/rollupStore.js.map +1 -0
- package/dist/component/rollups.d.ts +20 -0
- package/dist/component/rollups.d.ts.map +1 -0
- package/dist/component/rollups.js +73 -0
- package/dist/component/rollups.js.map +1 -0
- package/dist/component/sanitize.d.ts +24 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +96 -16
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +687 -65
- package/dist/component/schema.js +187 -20
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts +12 -0
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +41 -7
- package/dist/component/sites.js.map +1 -1
- package/dist/component/useragent.d.ts +9 -0
- package/dist/component/useragent.d.ts.map +1 -0
- package/dist/component/useragent.js +152 -0
- package/dist/component/useragent.js.map +1 -0
- package/dist/component/validators.d.ts +124 -69
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -14
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +19 -0
- package/dist/component/visitors.d.ts.map +1 -0
- package/dist/component/visitors.js +86 -0
- package/dist/component/visitors.js.map +1 -0
- package/dist/component/vitals.d.ts +41 -0
- package/dist/component/vitals.d.ts.map +1 -0
- package/dist/component/vitals.js +115 -0
- package/dist/component/vitals.js.map +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/tracker/generated.d.ts +11 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +11 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +1 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +57 -20
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts +10 -0
- package/dist/tracker/vitals.d.ts.map +1 -0
- package/dist/tracker/vitals.js +140 -0
- package/dist/tracker/vitals.js.map +1 -0
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -0
- package/docs/benchmarks/2026-08-20-realistic.md +76 -76
- package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
- package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
- package/docs/federation-setup.md +464 -0
- package/docs/federation.md +352 -0
- package/docs/upgrading.md +344 -0
- package/llms.txt +72 -0
- package/package.json +55 -11
- package/scripts/benchmark-ingest.mjs +175 -73
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +22 -0
- package/src/component/_generated/component.ts +381 -4
- package/src/component/_generated/server.ts +4 -0
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +117 -0
- package/src/component/constants.ts +44 -1
- package/src/component/convex.config.ts +11 -1
- package/src/component/coverage.ts +71 -0
- package/src/component/diagnostics.ts +65 -0
- package/src/component/errors.ts +2 -1
- package/src/component/eventStore.ts +217 -193
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +835 -0
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +404 -70
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +894 -188
- package/src/component/live.ts +13 -7
- package/src/component/localTime.ts +167 -0
- package/src/component/reports.ts +1872 -197
- package/src/component/retention.ts +788 -96
- package/src/component/rollupStore.ts +799 -0
- package/src/component/rollups.ts +82 -0
- package/src/component/sanitize.ts +144 -29
- package/src/component/schema.ts +217 -21
- package/src/component/sites.ts +59 -12
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +49 -14
- package/src/component/visitors.ts +116 -0
- 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
|
|
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
|

|
|
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
|
|
19
|
-
| Traffic analytics | Sharded hour/day rollups, time series, top pages, sources, countries, devices, browsers, and events
|
|
20
|
-
|
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
-
|
|
294
|
-
|
|
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:
|
|
297
|
-
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
|
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 `
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
348
|
-
bucket is written at most once per
|
|
349
|
-
explicit `(other)` bucket instead of
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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,
|
|
1030
|
+
retention, request metadata and GeoIP, exact byte boundaries, HTTP routes, React
|
|
1031
|
+
integration, and tracker size/behavior.
|
|
410
1032
|
|
|
411
|
-
## Synapse
|
|
1033
|
+
## Synapse deployments
|
|
412
1034
|
|
|
413
|
-
The
|
|
414
|
-
|
|
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
|
|