@iann29/rastro 0.1.0-alpha.4 → 0.1.0-alpha.6

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 (135) hide show
  1. package/README.md +203 -56
  2. package/agent/integration.md +17 -15
  3. package/agent/manifest.json +6 -4
  4. package/agent/manifest.schema.json +8 -5
  5. package/dist/client/federation.d.ts +34 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +17 -3
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +486 -31
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +209 -41
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +8 -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 +82 -8
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/affiliates.d.ts.map +1 -1
  19. package/dist/component/affiliates.js +6 -2
  20. package/dist/component/affiliates.js.map +1 -1
  21. package/dist/component/cardinality.d.ts.map +1 -1
  22. package/dist/component/cardinality.js.map +1 -1
  23. package/dist/component/constants.d.ts +6 -0
  24. package/dist/component/constants.d.ts.map +1 -1
  25. package/dist/component/constants.js +12 -0
  26. package/dist/component/constants.js.map +1 -1
  27. package/dist/component/diagnostics.d.ts.map +1 -1
  28. package/dist/component/diagnostics.js.map +1 -1
  29. package/dist/component/eventStore.d.ts +3 -1
  30. package/dist/component/eventStore.d.ts.map +1 -1
  31. package/dist/component/eventStore.js +13 -10
  32. package/dist/component/eventStore.js.map +1 -1
  33. package/dist/component/funnels.d.ts.map +1 -1
  34. package/dist/component/funnels.js +5 -3
  35. package/dist/component/funnels.js.map +1 -1
  36. package/dist/component/geo.d.ts.map +1 -1
  37. package/dist/component/geo.js +3 -2
  38. package/dist/component/geo.js.map +1 -1
  39. package/dist/component/goals.d.ts.map +1 -1
  40. package/dist/component/goals.js +8 -5
  41. package/dist/component/goals.js.map +1 -1
  42. package/dist/component/guards.d.ts.map +1 -1
  43. package/dist/component/guards.js.map +1 -1
  44. package/dist/component/http.d.ts.map +1 -1
  45. package/dist/component/http.js +71 -37
  46. package/dist/component/http.js.map +1 -1
  47. package/dist/component/identity.d.ts +13 -0
  48. package/dist/component/identity.d.ts.map +1 -0
  49. package/dist/component/identity.js +58 -0
  50. package/dist/component/identity.js.map +1 -0
  51. package/dist/component/ingest.d.ts +3 -1
  52. package/dist/component/ingest.d.ts.map +1 -1
  53. package/dist/component/ingest.js +168 -29
  54. package/dist/component/ingest.js.map +1 -1
  55. package/dist/component/live.d.ts.map +1 -1
  56. package/dist/component/live.js +1 -3
  57. package/dist/component/live.js.map +1 -1
  58. package/dist/component/reports.d.ts +70 -4
  59. package/dist/component/reports.d.ts.map +1 -1
  60. package/dist/component/reports.js +232 -59
  61. package/dist/component/reports.js.map +1 -1
  62. package/dist/component/retention.d.ts +4 -4
  63. package/dist/component/retention.d.ts.map +1 -1
  64. package/dist/component/retention.js +54 -22
  65. package/dist/component/retention.js.map +1 -1
  66. package/dist/component/sanitize.d.ts.map +1 -1
  67. package/dist/component/sanitize.js +42 -11
  68. package/dist/component/sanitize.js.map +1 -1
  69. package/dist/component/schema.d.ts +83 -10
  70. package/dist/component/schema.js +34 -1
  71. package/dist/component/schema.js.map +1 -1
  72. package/dist/component/sites.d.ts.map +1 -1
  73. package/dist/component/sites.js +6 -6
  74. package/dist/component/sites.js.map +1 -1
  75. package/dist/component/useragent.d.ts +9 -0
  76. package/dist/component/useragent.d.ts.map +1 -0
  77. package/dist/component/useragent.js +152 -0
  78. package/dist/component/useragent.js.map +1 -0
  79. package/dist/component/validators.d.ts +29 -18
  80. package/dist/component/validators.d.ts.map +1 -1
  81. package/dist/component/validators.js +8 -2
  82. package/dist/component/validators.js.map +1 -1
  83. package/dist/component/visitors.d.ts +19 -0
  84. package/dist/component/visitors.d.ts.map +1 -0
  85. package/dist/component/visitors.js +86 -0
  86. package/dist/component/visitors.js.map +1 -0
  87. package/dist/component/vitals.d.ts +41 -0
  88. package/dist/component/vitals.d.ts.map +1 -0
  89. package/dist/component/vitals.js +115 -0
  90. package/dist/component/vitals.js.map +1 -0
  91. package/dist/react/index.d.ts.map +1 -1
  92. package/dist/react/index.js.map +1 -1
  93. package/dist/tracker/generated.d.ts +8 -4
  94. package/dist/tracker/generated.d.ts.map +1 -1
  95. package/dist/tracker/generated.js +8 -4
  96. package/dist/tracker/generated.js.map +1 -1
  97. package/dist/tracker/tracker.d.ts.map +1 -1
  98. package/dist/tracker/tracker.js +23 -11
  99. package/dist/tracker/tracker.js.map +1 -1
  100. package/dist/tracker/vitals.d.ts +10 -0
  101. package/dist/tracker/vitals.d.ts.map +1 -0
  102. package/dist/tracker/vitals.js +137 -0
  103. package/dist/tracker/vitals.js.map +1 -0
  104. package/dist/tracker.min.js +1 -1
  105. package/dist/vitals.min.js +1 -0
  106. package/docs/benchmarks/2026-08-20-realistic.md +71 -71
  107. package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
  108. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  109. package/docs/federation.md +1 -1
  110. package/package.json +27 -9
  111. package/scripts/benchmark-ingest.mjs +101 -48
  112. package/src/component/_generated/api.ts +8 -0
  113. package/src/component/_generated/component.ts +87 -5
  114. package/src/component/affiliates.ts +20 -5
  115. package/src/component/cardinality.ts +8 -7
  116. package/src/component/constants.ts +12 -0
  117. package/src/component/diagnostics.ts +3 -2
  118. package/src/component/eventStore.ts +53 -46
  119. package/src/component/funnels.ts +19 -16
  120. package/src/component/geo.ts +16 -14
  121. package/src/component/goals.ts +29 -21
  122. package/src/component/guards.ts +3 -1
  123. package/src/component/http.ts +126 -78
  124. package/src/component/identity.ts +74 -0
  125. package/src/component/ingest.ts +349 -130
  126. package/src/component/live.ts +5 -4
  127. package/src/component/reports.ts +572 -221
  128. package/src/component/retention.ts +221 -98
  129. package/src/component/sanitize.ts +77 -27
  130. package/src/component/schema.ts +46 -5
  131. package/src/component/sites.ts +22 -11
  132. package/src/component/useragent.ts +171 -0
  133. package/src/component/validators.ts +19 -7
  134. package/src/component/visitors.ts +116 -0
  135. package/src/component/vitals.ts +146 -0
@@ -0,0 +1,206 @@
1
+ # Ingest recertification, 2026-08-30
2
+
3
+ This report recertifies the four workload profiles ahead of `0.1.0-alpha.6`,
4
+ against the same deployment and benchmark site as the
5
+ [2026-08-21 formal certification](2026-08-21-formal-certification.md). It is
6
+ engineering evidence for the tested revisions and deployment state, not a
7
+ production SLA.
8
+
9
+ ## Environment
10
+
11
+ | Item | Value |
12
+ | ------------------- | ---------------------------------------------------------------- |
13
+ | Deployment | DEV canary `fast-finch-7454` (unchanged since 2026-08-21) |
14
+ | Benchmark site | `kx75pnr9wap522n0zmh4nnb4jh8cv110` (unchanged) |
15
+ | Lean run revision | `ba9eff1` (main before Web Vitals) |
16
+ | Later run revisions | Web Vitals build; component sources identical to merge `6250526` |
17
+ | Driver | One local Node.js process |
18
+ | Reconciliation | Exact run-scoped session IDs, indexed pages of 100 |
19
+ | GeoIP provider | Disabled |
20
+ | Health before runs | `ok: true` |
21
+
22
+ Since the 2026-08-21 certification the wire path gained salted daily visitor
23
+ keys, User-Agent bot rejection, client-hint browser detection, the federation
24
+ hardening, and the `vital` ingest branch (present but idle: no profile sends
25
+ vitals). Unlike 2026-08-21, the canary was not fresh: it accumulated the
26
+ battery's own multi-million-event history while the profiles ran, which is the
27
+ honest way most deployments look after a real day.
28
+
29
+ All certified runs used `--duration 600 --certify`, ran sequentially with at
30
+ least five minutes of cooldown, and had no competing local load. Two runs were
31
+ discarded and repeated because the local DNS resolver failed their first
32
+ connection wave (a client-side condition — the deployment was idle and healthy
33
+ each time); one more was discarded after a mid-run deploy from this same
34
+ machine. Discarded runs are not reported as capacity.
35
+
36
+ ## Results
37
+
38
+ Meeting the 100 million stored-events/day target requires at least 1,157.41
39
+ stored events/second.
40
+
41
+ | Profile | Concurrency | Batch | Sessions | Accepted | Stored | Stored/s | Certified stored/day | p95 | Exact reconciliation | 100M/day target |
42
+ | --------- | ----------: | ----: | -------: | --------: | --------: | -------: | -------------------: | -------: | --------------------- | ---------------- |
43
+ | Lean | 100 | 50 | unique | 1,247,000 | 1,247,000 | 2,071 | 178,948,552 | 4,167 ms | 1,247,000 / 1,247,000 | Pass |
44
+ | Realistic | 75 | 50 | 4,096 | 1,039,050 | 988,995 | 1,644 | 142,036,529 | 4,953 ms | 988,995 / 988,995 | Pass |
45
+ | Heavy | 100 | 20 | 2,048 | 388,040 | 388,040 | 640 | 55,259,642 | 7,702 ms | 388,040 / 388,040 | **Below target** |
46
+ | Feature | 100 | 15 | 2,048 | — | — | — | **failed** | — | — | **Failed** |
47
+
48
+ ### Lean
49
+
50
+ Certified by the harness: 24,940 requests, zero failures, duplicates, or
51
+ rejections; exact reconciliation. 178.95M stored events/day is 31% above the
52
+ 2026-08-21 result (136.65M) on the same deployment — the byte-measurement reuse
53
+ first recertified for heavy on 2026-08-22 now shows up across the board, and the
54
+ new per-request work (bot check, client hints, salted visitor keys) did not cost
55
+ the hot path its headroom.
56
+
57
+ ### Realistic
58
+
59
+ The driver's session pool was doubled to 4,096 (default 2,048) and that change
60
+ is part of this row's workload definition. At the current deployment speed the
61
+ 2,048-session pool drives individual sessions past the documented 120
62
+ events/session/minute ingest budget, so a prior attempt lost 2 of 20,477
63
+ requests to `429 RATE_LIMITED` — the limiter working as documented, but not a
64
+ clean certification. With 4,096 sessions the run completed 20,781 requests with
65
+ **zero failures** of any kind.
66
+
67
+ The harness itself did not stamp this run: after the clean 600 s window, its
68
+ first reconciliation query lost a fetch to the local resolver
69
+ (`reconciliation:fetch failed`) and gave up. Reconciliation was re-run
70
+ immediately afterwards with the same `benchmark:reconcileSessionsPage` pages the
71
+ harness uses, with per-page retries:
72
+
73
+ ```json
74
+ {
75
+ "runId": "3c81abe6-60cd-4b40-828f-cedf03fedd39",
76
+ "requested": 4096,
77
+ "found": 4096,
78
+ "stored": 988995,
79
+ "expectedStored": 988995
80
+ }
81
+ ```
82
+
83
+ 988,995 expected stored events (1,039,050 accepted − 50,055 heartbeats), 988,995
84
+ found — exact. 988,995 stored over the 601.6 s window is 1,644 stored/s, 142.04M
85
+ stored events/day, 17.6% above the 2026-08-21 result (120.81M).
86
+
87
+ ### Heavy
88
+
89
+ Certified by the harness: 19,402 requests, zero failures, exact reconciliation,
90
+ 1,990,190 request bytes/second under the 64 KiB body limit. 55.26M stored
91
+ events/day is 28% below the 2026-08-22 fresh-store recertification (76.62M). The
92
+ payload and validation are unchanged; the store was not. This run followed hours
93
+ of sustained battery load on a canary holding millions of freshly written rows,
94
+ where 2026-08-22 hit a much lighter store. Both numbers stand side by side:
95
+ 76.62M remains the fresh-store ceiling, 55.26M is the same workload on a loaded
96
+ store. Neither is overwritten by the other without a matched-state rerun.
97
+
98
+ ### Feature — certification FAILED (reproducible defect)
99
+
100
+ The feature profile (goals, funnels, and affiliate attribution all matching)
101
+ cannot currently be certified: a steady ~4–5% of requests fail with
102
+ `500 INTERNAL_ERROR` at every concurrency tried (100, 15, and 5), and each
103
+ failed request loses its whole batch — reconciliation of a 60 s smoke found only
104
+ 946 of 997 run sessions, so this is data loss under load. Backend logs show OCC
105
+ retry exhaustion on the affiliate daily rollups, always on the same two
106
+ documents — the two `REPORT_ROLLUP_SHARDS` rows for the demo affiliate's day:
107
+
108
+ ```text
109
+ [CONVEX M(ingest:ingestBatch)] Documents read from or written to the
110
+ "affiliateDailyRollups" table changed while this mutation was being run and on
111
+ every subsequent retry. Another call to this mutation changed the document
112
+ with ID "mh735zse9410dt27ftc8t3tv4s8deazf".
113
+ ```
114
+
115
+ The defect postdates alpha.3 (the 2026-08-21 feature run predates the
116
+ generation-1 daily rollups and passed clean at 384 stored/s) and predates the
117
+ Web Vitals change, whose fold path is separate. It is tracked as
118
+ [issue #52](https://github.com/amageweb/amage-rastro/issues/52); the fix needs
119
+ wider rollup sharding, rebalanced feature-report read budgets, and a
120
+ rollout-generation bump. Until it lands, no feature-profile capacity is claimed
121
+ at all — the 2026-08-21 33.22M row is historical evidence for alpha.3 only.
122
+
123
+ ## Certified run JSON
124
+
125
+ ### Lean (`cert-lean-alpha5`, run `862673e3-ff44-492e-a078-5d8c33f70101`)
126
+
127
+ ```json
128
+ {
129
+ "requests": { "started": 24940, "completed": 24940, "failures": 0 },
130
+ "events": {
131
+ "sent": 1247000,
132
+ "heartbeats": 0,
133
+ "accepted": 1247000,
134
+ "stored": 1247000,
135
+ "duplicates": 0,
136
+ "rejected": 0,
137
+ "failed": 0
138
+ },
139
+ "throughput": {
140
+ "storedEventsPerSecond": 2071,
141
+ "certifiedStoredEventsPerDay": 178948552,
142
+ "requestBytesPerSecond": 575501
143
+ },
144
+ "latencyMs": { "p50": 2156, "p95": 4167, "p99": 5925, "max": 7189 },
145
+ "reconciliation": {
146
+ "expectedStoredEvents": 1247000,
147
+ "storedEvents": 1247000,
148
+ "matches": true
149
+ }
150
+ }
151
+ ```
152
+
153
+ ### Heavy (`cert-heavy-alpha5-r2`)
154
+
155
+ ```json
156
+ {
157
+ "requests": { "started": 19402, "completed": 19402, "failures": 0 },
158
+ "events": {
159
+ "sent": 388040,
160
+ "accepted": 388040,
161
+ "stored": 388040,
162
+ "duplicates": 0,
163
+ "rejected": 0,
164
+ "failed": 0
165
+ },
166
+ "throughput": {
167
+ "storedEventsPerSecond": 640,
168
+ "certifiedStoredEventsPerDay": 55259642,
169
+ "requestBytesPerSecond": 1990190
170
+ },
171
+ "latencyMs": { "p50": 2545, "p95": 7702, "p99": 10325, "max": 11220 },
172
+ "reconciliation": {
173
+ "expectedStoredEvents": 388040,
174
+ "storedEvents": 388040,
175
+ "matches": true
176
+ }
177
+ }
178
+ ```
179
+
180
+ ### Realistic (`cert-realistic-alpha5-r3-a1`, run `3c81abe6-60cd-4b40-828f-cedf03fedd39`)
181
+
182
+ ```json
183
+ {
184
+ "requests": { "started": 20781, "completed": 20781, "failures": 0 },
185
+ "events": {
186
+ "sent": 1039050,
187
+ "heartbeats": 50055,
188
+ "accepted": 1039050,
189
+ "duplicates": 0,
190
+ "rejected": 0,
191
+ "failed": 0
192
+ },
193
+ "throughput": {
194
+ "eventsPerSecond": 1727,
195
+ "requestBytesPerSecond": 523501
196
+ },
197
+ "latencyMs": { "p50": 1790, "p95": 4953, "p99": 7096, "max": 9274 },
198
+ "reconciliation": "lost to a client-side DNS failure; re-run exactly, see above"
199
+ }
200
+ ```
201
+
202
+ ## What still lacks certification
203
+
204
+ GeoIP with a provider enabled, trusted server-side conversions, vitals-bearing
205
+ traffic (the `vital` ingest branch ran idle here), and the feature profile until
206
+ issue #52 is fixed.
@@ -239,7 +239,7 @@ either overview dataset.
239
239
  ## Control-plane status
240
240
 
241
241
  The production control plane at `https://www.amagerastro.com` and its reference
242
- implementation under `example/` provide Better Auth sessions,
242
+ implementation under `control-plane/` provide Better Auth sessions,
243
243
  organization-scoped connections, OIDC discovery, JWKS, ten-minute RS256 tokens,
244
244
  manifest verification, redacted site discovery, audit records, revocation of new
245
245
  token issuance, and dynamic browser subscriptions to active deployments.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@iann29/rastro",
3
- "description": "Privacy-first, real-time web analytics for Convex: live visitor atlas, journeys, revenue, goals, funnels, affiliates, and a sub-1KB tracker.",
3
+ "description": "Privacy-first, real-time web analytics for Convex: live visitor atlas, journeys, revenue, goals, funnels, affiliates, and a 1.1 KB tracker.",
4
4
  "repository": "github:amageweb/amage-rastro",
5
5
  "homepage": "https://github.com/amageweb/amage-rastro#readme",
6
6
  "bugs": {
7
7
  "url": "https://github.com/amageweb/amage-rastro/issues"
8
8
  },
9
- "version": "0.1.0-alpha.4",
9
+ "version": "0.1.0-alpha.6",
10
10
  "license": "Apache-2.0",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -27,9 +27,9 @@
27
27
  "jsdelivr": "./dist/tracker.min.js",
28
28
  "scripts": {
29
29
  "dev": "synapse dev --start 'npm run dev:build'",
30
- "dev:frontend": "cd example && vite",
30
+ "dev:frontend": "cd control-plane && vite",
31
31
  "dev:build": "chokidar 'tsconfig*.json' 'src/**/*.{ts,js}' 'scripts/build-tracker.mjs' -i '**/*.test.ts' -c 'npm run build:codegen' --initial",
32
- "deploy:dashboard": "static-hosting upload --build --dist example/dist --build-command \"npm exec vite -- build example\"",
32
+ "deploy:dashboard": "static-hosting upload --build --dist control-plane/dist --build-command \"npm exec vite -- build control-plane\"",
33
33
  "predev": "npm run build:codegen",
34
34
  "prebuild": "rm -rf dist *.tsbuildinfo",
35
35
  "build": "npm run build:tracker && tsc --project ./tsconfig.build.json",
@@ -37,17 +37,21 @@
37
37
  "federation:keys": "node ./scripts/generate-federation-keys.mjs",
38
38
  "benchmark:ingest": "node ./scripts/benchmark-ingest.mjs",
39
39
  "benchmark:local": "vitest run src/component/benchmark.test.ts",
40
+ "format:check": "prettier --check .",
40
41
  "verify:agent-docs": "node ./scripts/verify-agent-docs.mjs",
41
- "build:codegen": "npm run build && synapse convex codegen --component-dir ./src/component && npm run build && synapse dev --once",
42
+ "verify:skill-paths": "node ./scripts/verify-skill-paths.mjs",
43
+ "build:codegen": "npm run build && synapse convex codegen --component-dir ./src/component && npm run build && npm run codegen:example && synapse dev --once",
44
+ "codegen:example": "npm run --workspace example codegen",
42
45
  "build:clean": "npm run build:codegen",
43
- "typecheck": "tsc --noEmit && tsc -p example && tsc -p example/convex",
46
+ "typecheck": "tsc --noEmit && tsc -p control-plane && tsc -p control-plane/convex && tsc -p example/convex",
44
47
  "lint": "eslint .",
45
48
  "test": "vitest run --typecheck",
46
49
  "test:watch": "vitest --typecheck --clearScreen false",
47
50
  "test:debug": "vitest --inspect-brk --no-file-parallelism",
48
51
  "test:coverage": "vitest run --coverage --coverage.reporter=text",
52
+ "e2e": "playwright test -c control-plane/playwright.config.ts",
49
53
  "prepare": "npm run build",
50
- "preversion": "npm run build && npm exec vite -- build example && npm run test && npm run lint && npm run typecheck && npm run benchmark:local && npm run verify:agent-docs"
54
+ "preversion": "npm run build && VITE_CONVEX_URL=https://preflight.example.convex.cloud VITE_CONVEX_SITE_URL=https://preflight.example.convex.site npm exec vite -- build control-plane && npm run test && npm run format:check && npm run lint && npm run typecheck && npm run benchmark:local && npm run verify:agent-docs && npm run verify:skill-paths"
51
55
  },
52
56
  "files": [
53
57
  "dist",
@@ -77,6 +81,7 @@
77
81
  "default": "./dist/react/index.js"
78
82
  },
79
83
  "./tracker.js": "./dist/tracker.min.js",
84
+ "./vitals.js": "./dist/vitals.min.js",
80
85
  "./test": "./src/test.ts",
81
86
  "./_generated/component.js": {
82
87
  "types": "./dist/component/_generated/component.d.ts"
@@ -105,12 +110,19 @@
105
110
  "devDependencies": {
106
111
  "@convex-dev/eslint-plugin": "^4.0.0",
107
112
  "@convex-dev/static-hosting": "^0.2.1",
113
+ "@dicebear/core": "^9.4.3",
114
+ "@dicebear/lorelei": "^9.4.2",
108
115
  "@edge-runtime/vm": "^5.0.0",
109
116
  "@eslint/eslintrc": "^3.3.6",
110
117
  "@eslint/js": "9.39.5",
111
118
  "@fontsource-variable/instrument-sans": "^5.3.0",
112
119
  "@fontsource/ibm-plex-mono": "^5.3.0",
120
+ "@playwright/test": "^1.62.1",
113
121
  "@tailwindcss/vite": "^4.3.3",
122
+ "@testing-library/dom": "^10.4.1",
123
+ "@testing-library/jest-dom": "^7.0.1",
124
+ "@testing-library/react": "^16.3.3",
125
+ "@testing-library/user-event": "^14.6.6",
114
126
  "@types/node": "^24.13.3",
115
127
  "@types/react": "^19.2.18",
116
128
  "@types/react-dom": "^19.2.4",
@@ -128,6 +140,7 @@
128
140
  "eslint-plugin-react-hooks": "^7.1.1",
129
141
  "eslint-plugin-react-refresh": "^0.5.3",
130
142
  "globals": "^17.9.0",
143
+ "jsdom": "^30.0.1",
131
144
  "lucide-react": "^1.32.0",
132
145
  "maplibre-gl": "^6.4.1",
133
146
  "pkg-pr-new": "^0.0.86",
@@ -148,6 +161,11 @@
148
161
  "dependencies": {
149
162
  "@convex-dev/better-auth": "^0.12.5",
150
163
  "better-auth": "~1.6.15",
151
- "jose": "^6.2.10"
152
- }
164
+ "country-flag-icons": "^1.6.20",
165
+ "jose": "^6.2.10",
166
+ "simple-icons": "^16.29.0"
167
+ },
168
+ "workspaces": [
169
+ "example"
170
+ ]
153
171
  }
@@ -67,16 +67,19 @@ function parseOptions(argv) {
67
67
  const values = new Map();
68
68
  for (let index = 0; index < argv.length; index += 1) {
69
69
  const argument = argv[index];
70
- if (!argument.startsWith("--")) throw new Error(`unexpected argument: ${argument}`);
70
+ if (!argument.startsWith("--"))
71
+ throw new Error(`unexpected argument: ${argument}`);
71
72
  const [rawName, inlineValue] = argument.slice(2).split("=", 2);
72
- if (!OPTION_NAMES.has(rawName)) throw new Error(`unknown option: --${rawName}`);
73
+ if (!OPTION_NAMES.has(rawName))
74
+ throw new Error(`unknown option: --${rawName}`);
73
75
  if (values.has(rawName)) throw new Error(`duplicate option: --${rawName}`);
74
76
  if (rawName === "dry-run" || rawName === "certify") {
75
77
  values.set(rawName, "true");
76
78
  continue;
77
79
  }
78
80
  const value = inlineValue ?? argv[++index];
79
- if (!value || value.startsWith("--")) throw new Error(`missing value for --${rawName}`);
81
+ if (!value || value.startsWith("--"))
82
+ throw new Error(`missing value for --${rawName}`);
80
83
  values.set(rawName, value);
81
84
  }
82
85
  const profile = values.get("profile") ?? "realistic";
@@ -95,7 +98,10 @@ function parseOptions(argv) {
95
98
  maximumRequests: values.has("requests")
96
99
  ? positiveInteger(values.get("requests"), "requests")
97
100
  : Number.POSITIVE_INFINITY,
98
- concurrency: positiveInteger(values.get("concurrency") ?? "100", "concurrency"),
101
+ concurrency: positiveInteger(
102
+ values.get("concurrency") ?? "100",
103
+ "concurrency",
104
+ ),
99
105
  batchSize: positiveInteger(
100
106
  values.get("batch-size") ?? String(defaults.batchSize),
101
107
  "batch-size",
@@ -112,20 +118,23 @@ function parseOptions(argv) {
112
118
 
113
119
  function positiveNumber(value, name) {
114
120
  const parsed = Number(value);
115
- if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`--${name} must be positive`);
121
+ if (!Number.isFinite(parsed) || parsed <= 0)
122
+ throw new Error(`--${name} must be positive`);
116
123
  return parsed;
117
124
  }
118
125
 
119
126
  function positiveInteger(value, name) {
120
127
  const parsed = positiveNumber(value, name);
121
- if (!Number.isSafeInteger(parsed)) throw new Error(`--${name} must be an integer`);
128
+ if (!Number.isSafeInteger(parsed))
129
+ throw new Error(`--${name} must be an integer`);
122
130
  return parsed;
123
131
  }
124
132
 
125
133
  function eventFor(profile, session, sequence, timestamp) {
126
- const path = profile === "lean"
127
- ? `/benchmark/lean/${sequence % 4}`
128
- : REALISTIC_PATHS[sequence % REALISTIC_PATHS.length];
134
+ const path =
135
+ profile === "lean"
136
+ ? `/benchmark/lean/${sequence % 4}`
137
+ : REALISTIC_PATHS[sequence % REALISTIC_PATHS.length];
129
138
  const base = {
130
139
  eventId: `${session.id}.${sequence.toString(36)}`,
131
140
  sessionId: session.id,
@@ -151,10 +160,20 @@ function eventFor(profile, session, sequence, timestamp) {
151
160
  if (profile === "feature") {
152
161
  const step = sequence % 3;
153
162
  if (step === 0) {
154
- return { ...base, type: "pageview", path: "/", affiliateSlug: "creator-club" };
163
+ return {
164
+ ...base,
165
+ type: "pageview",
166
+ path: "/",
167
+ affiliateSlug: "creator-club",
168
+ };
155
169
  }
156
170
  if (step === 1) {
157
- return { ...base, type: "pageview", path: "/pricing", affiliateSlug: "creator-club" };
171
+ return {
172
+ ...base,
173
+ type: "pageview",
174
+ path: "/pricing",
175
+ affiliateSlug: "creator-club",
176
+ };
158
177
  }
159
178
  return {
160
179
  ...base,
@@ -172,7 +191,10 @@ function eventFor(profile, session, sequence, timestamp) {
172
191
  return {
173
192
  ...base,
174
193
  type: "pageview",
175
- referrer: sequence % 2 === 0 ? "https://www.google.com/search?q=rastro" : undefined,
194
+ referrer:
195
+ sequence % 2 === 0
196
+ ? "https://www.google.com/search?q=rastro"
197
+ : undefined,
176
198
  };
177
199
  }
178
200
  if (kind < 13) return { ...base, type: "click", target: "Get started" };
@@ -184,7 +206,11 @@ function eventFor(profile, session, sequence, timestamp) {
184
206
  properties: { placement: "hero", experiment: `variant-${sequence % 3}` },
185
207
  };
186
208
  }
187
- return { ...base, type: "outbound", href: "https://github.com/amageweb/amage-rastro" };
209
+ return {
210
+ ...base,
211
+ type: "outbound",
212
+ href: "https://github.com/amageweb/amage-rastro",
213
+ };
188
214
  }
189
215
 
190
216
  function requestContext(session) {
@@ -200,15 +226,19 @@ function requestContext(session) {
200
226
 
201
227
  function percentile(sorted, fraction) {
202
228
  if (sorted.length === 0) return 0;
203
- return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)];
229
+ return sorted[
230
+ Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
231
+ ];
204
232
  }
205
233
 
206
234
  function hasValidSessionTotals(value) {
207
- return value !== null &&
235
+ return (
236
+ value !== null &&
208
237
  typeof value === "object" &&
209
238
  [value.requestedSessions, value.foundSessions, value.storedEvents].every(
210
239
  (total) => Number.isSafeInteger(total) && total >= 0,
211
- );
240
+ )
241
+ );
212
242
  }
213
243
 
214
244
  async function main() {
@@ -249,15 +279,16 @@ async function main() {
249
279
  const durations = [];
250
280
 
251
281
  const buildRequest = (requestIndex) => {
252
- const session = options.profile === "lean"
253
- ? {
254
- id: `bench-${runId}-${requestIndex}`,
255
- visitorId: `visitor-${runId}-${requestIndex}`,
256
- sequence: 0,
257
- locationIndex: requestIndex % LOCATIONS.length,
258
- userAgentIndex: requestIndex % USER_AGENTS.length,
259
- }
260
- : sessions[requestIndex % sessions.length];
282
+ const session =
283
+ options.profile === "lean"
284
+ ? {
285
+ id: `bench-${runId}-${requestIndex}`,
286
+ visitorId: `visitor-${runId}-${requestIndex}`,
287
+ sequence: 0,
288
+ locationIndex: requestIndex % LOCATIONS.length,
289
+ userAgentIndex: requestIndex % USER_AGENTS.length,
290
+ }
291
+ : sessions[requestIndex % sessions.length];
261
292
  const sequenceStart = session.sequence;
262
293
  session.sequence += options.batchSize;
263
294
  const now = Date.now();
@@ -276,18 +307,26 @@ async function main() {
276
307
  throw new Error("generated request exceeds the 64 KiB HTTP limit");
277
308
  }
278
309
  if (options.dryRun) {
279
- process.stdout.write(`${JSON.stringify({
280
- profile: options.profile,
281
- batchSize: options.batchSize,
282
- requestBytes: Buffer.byteLength(dryRequest.body),
283
- heartbeats: dryRequest.heartbeats,
284
- location: requestContext(sessions[0]),
285
- }, null, 2)}\n`);
310
+ process.stdout.write(
311
+ `${JSON.stringify(
312
+ {
313
+ profile: options.profile,
314
+ batchSize: options.batchSize,
315
+ requestBytes: Buffer.byteLength(dryRequest.body),
316
+ heartbeats: dryRequest.heartbeats,
317
+ location: requestContext(sessions[0]),
318
+ },
319
+ null,
320
+ 2,
321
+ )}\n`,
322
+ );
286
323
  return;
287
324
  }
288
325
  if (options.profile !== "lean") sessions[0].sequence = 0;
289
326
  if (!options.url || !options.siteId) {
290
- throw new Error("--url and --site-id are required unless --dry-run is used");
327
+ throw new Error(
328
+ "--url and --site-id are required unless --dry-run is used",
329
+ );
291
330
  }
292
331
 
293
332
  const stopForSignal = (signal) => {
@@ -299,7 +338,9 @@ async function main() {
299
338
  process.once("SIGINT", stopForInterrupt);
300
339
  process.once("SIGTERM", stopForTermination);
301
340
 
302
- const convex = options.convexUrl ? new ConvexHttpClient(options.convexUrl) : null;
341
+ const convex = options.convexUrl
342
+ ? new ConvexHttpClient(options.convexUrl)
343
+ : null;
303
344
  if (convex) {
304
345
  const preflight = await convex.query(reconcileSessionsQuery, {
305
346
  siteId: options.siteId,
@@ -333,7 +374,9 @@ async function main() {
333
374
  const request = buildRequest(requestIndex);
334
375
  const requestBytes = Buffer.byteLength(request.body);
335
376
  if (requestBytes > 64 * 1024) {
336
- throw new Error(`generated request ${requestIndex} exceeds the 64 KiB HTTP limit`);
377
+ throw new Error(
378
+ `generated request ${requestIndex} exceeds the 64 KiB HTTP limit`,
379
+ );
337
380
  }
338
381
  sentBytes += requestBytes;
339
382
  sentEvents += options.batchSize;
@@ -368,7 +411,8 @@ async function main() {
368
411
  ![result.accepted, result.duplicates, result.rejected].every(
369
412
  (value) => Number.isSafeInteger(value) && value >= 0,
370
413
  ) ||
371
- result.accepted + result.duplicates + result.rejected !== options.batchSize
414
+ result.accepted + result.duplicates + result.rejected !==
415
+ options.batchSize
372
416
  ) {
373
417
  throw new Error("response has invalid event accounting");
374
418
  }
@@ -384,7 +428,9 @@ async function main() {
384
428
  } catch (error) {
385
429
  failures += 1;
386
430
  failedEvents += options.batchSize;
387
- recordError(`fetch:${error instanceof Error ? error.message : "unknown"}`);
431
+ recordError(
432
+ `fetch:${error instanceof Error ? error.message : "unknown"}`,
433
+ );
388
434
  } finally {
389
435
  durations.push(performance.now() - requestStartedAt);
390
436
  completedRequests += 1;
@@ -393,11 +439,14 @@ async function main() {
393
439
  };
394
440
 
395
441
  const progress = setInterval(() => {
396
- const elapsedSeconds = Math.max(0.001, (performance.now() - startedAt) / 1_000);
442
+ const elapsedSeconds = Math.max(
443
+ 0.001,
444
+ (performance.now() - startedAt) / 1_000,
445
+ );
397
446
  process.stderr.write(
398
447
  `[${options.label}] ${Math.round(elapsedSeconds)}s ` +
399
- `${accepted} accepted ${Math.round(accepted / elapsedSeconds)} events/s ` +
400
- `${failures} failures\n`,
448
+ `${accepted} accepted ${Math.round(accepted / elapsedSeconds)} events/s ` +
449
+ `${failures} failures\n`,
401
450
  );
402
451
  }, options.progressSeconds * 1_000);
403
452
  progress.unref();
@@ -405,14 +454,16 @@ async function main() {
405
454
  clearInterval(progress);
406
455
 
407
456
  const elapsedMs = performance.now() - startedAt;
408
- terminationReason ??= performance.now() >= deadline ? "duration" : "completed";
457
+ terminationReason ??=
458
+ performance.now() >= deadline ? "duration" : "completed";
409
459
  process.removeListener("SIGINT", stopForInterrupt);
410
460
  process.removeListener("SIGTERM", stopForTermination);
411
461
  const durationCompleted = terminationReason === "duration";
412
462
  durations.sort((left, right) => left - right);
413
- const expectedSessions = options.profile === "lean"
414
- ? nextRequest
415
- : Math.min(nextRequest, options.sessionCount);
463
+ const expectedSessions =
464
+ options.profile === "lean"
465
+ ? nextRequest
466
+ : Math.min(nextRequest, options.sessionCount);
416
467
  let sessionTotals = null;
417
468
  if (convex && expectedSessions > 0) {
418
469
  try {
@@ -490,7 +541,8 @@ async function main() {
490
541
  },
491
542
  concurrency: options.concurrency,
492
543
  batchSize: options.batchSize,
493
- sessionPool: options.profile === "lean" ? "unique-per-request" : options.sessionCount,
544
+ sessionPool:
545
+ options.profile === "lean" ? "unique-per-request" : options.sessionCount,
494
546
  maximumRequests: Number.isFinite(options.maximumRequests)
495
547
  ? options.maximumRequests
496
548
  : null,
@@ -513,9 +565,10 @@ async function main() {
513
565
  estimatedEventsPerDay: durationCompleted
514
566
  ? Math.round((accepted * 86_400_000) / elapsedMs)
515
567
  : null,
516
- estimatedStoredEventsPerDay: durationCompleted && reconciliationPassed
517
- ? Math.round((storedEvents * 86_400_000) / elapsedMs)
518
- : null,
568
+ estimatedStoredEventsPerDay:
569
+ durationCompleted && reconciliationPassed
570
+ ? Math.round((storedEvents * 86_400_000) / elapsedMs)
571
+ : null,
519
572
  certifiedEventsPerDay: certificationPassed
520
573
  ? Math.round((accepted * 86_400_000) / elapsedMs)
521
574
  : null,
@@ -20,13 +20,17 @@ import type * as geo from "../geo.js";
20
20
  import type * as goals from "../goals.js";
21
21
  import type * as guards from "../guards.js";
22
22
  import type * as http from "../http.js";
23
+ import type * as identity from "../identity.js";
23
24
  import type * as ingest from "../ingest.js";
24
25
  import type * as live from "../live.js";
25
26
  import type * as reports from "../reports.js";
26
27
  import type * as retention from "../retention.js";
27
28
  import type * as sanitize from "../sanitize.js";
28
29
  import type * as sites from "../sites.js";
30
+ import type * as useragent from "../useragent.js";
29
31
  import type * as validators from "../validators.js";
32
+ import type * as visitors from "../visitors.js";
33
+ import type * as vitals from "../vitals.js";
30
34
 
31
35
  import type {
32
36
  ApiFromModules,
@@ -48,13 +52,17 @@ const fullApi: ApiFromModules<{
48
52
  goals: typeof goals;
49
53
  guards: typeof guards;
50
54
  http: typeof http;
55
+ identity: typeof identity;
51
56
  ingest: typeof ingest;
52
57
  live: typeof live;
53
58
  reports: typeof reports;
54
59
  retention: typeof retention;
55
60
  sanitize: typeof sanitize;
56
61
  sites: typeof sites;
62
+ useragent: typeof useragent;
57
63
  validators: typeof validators;
64
+ visitors: typeof visitors;
65
+ vitals: typeof vitals;
58
66
  }> = anyApi as any;
59
67
 
60
68
  /**