@iann29/rastro 0.1.0-alpha.0 → 0.1.0-alpha.10
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 +579 -57
- package/agent/integration.md +661 -0
- package/agent/manifest.json +187 -0
- package/agent/manifest.schema.json +411 -0
- package/dist/client/federation.d.ts +236 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +196 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +2467 -18
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +461 -32
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +18 -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 +302 -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 +34 -7
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +47 -7
- 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 +19 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +30 -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 +106 -0
- package/dist/component/eventStore.d.ts.map +1 -0
- package/dist/component/eventStore.js +294 -0
- package/dist/component/eventStore.js.map +1 -0
- 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 +71 -0
- package/dist/component/geo.d.ts.map +1 -0
- package/dist/component/geo.js +611 -0
- package/dist/component/geo.js.map +1 -0
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +25 -9
- 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 +284 -53
- 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 +1105 -243
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts +8 -0
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +74 -9
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +273 -24
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1320 -150
- 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 +541 -31
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +24 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +107 -19
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +462 -55
- package/dist/component/schema.js +195 -15
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +38 -25
- 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 +101 -16
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +19 -2
- 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 +8 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +8 -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 +81 -33
- 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 +171 -0
- 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 +397 -0
- package/docs/federation.md +262 -0
- package/docs/upgrading.md +259 -0
- package/llms.txt +71 -0
- package/package.json +61 -11
- package/scripts/benchmark-ingest.mjs +601 -0
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +18 -0
- package/src/component/_generated/component.ts +366 -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 +47 -7
- package/src/component/convex.config.ts +11 -1
- package/src/component/coverage.ts +36 -0
- package/src/component/diagnostics.ts +65 -0
- package/src/component/errors.ts +2 -1
- package/src/component/eventStore.ts +489 -0
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +781 -0
- package/src/component/goals.ts +45 -16
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +403 -60
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +1652 -291
- package/src/component/live.ts +90 -9
- package/src/component/reports.ts +1929 -199
- package/src/component/retention.ts +774 -97
- package/src/component/sanitize.ts +154 -32
- package/src/component/schema.ts +219 -15
- package/src/component/sites.ts +56 -28
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +29 -1
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
import { clearInterval, setInterval } from "node:timers";
|
|
6
|
+
import { ConvexHttpClient } from "convex/browser";
|
|
7
|
+
import { makeFunctionReference } from "convex/server";
|
|
8
|
+
|
|
9
|
+
const reconcileSessionsQuery = makeFunctionReference(
|
|
10
|
+
"benchmark:reconcileSessionsPage",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const PROFILE_DEFAULTS = {
|
|
14
|
+
lean: { batchSize: 50, sessions: 0 },
|
|
15
|
+
realistic: { batchSize: 20, sessions: 2_048 },
|
|
16
|
+
heavy: { batchSize: 20, sessions: 2_048 },
|
|
17
|
+
feature: { batchSize: 15, sessions: 2_048 },
|
|
18
|
+
};
|
|
19
|
+
const OPTION_NAMES = new Set([
|
|
20
|
+
"url",
|
|
21
|
+
"convex-url",
|
|
22
|
+
"site-id",
|
|
23
|
+
"origin",
|
|
24
|
+
"label",
|
|
25
|
+
"profile",
|
|
26
|
+
"duration",
|
|
27
|
+
"requests",
|
|
28
|
+
"concurrency",
|
|
29
|
+
"batch-size",
|
|
30
|
+
"sessions",
|
|
31
|
+
"progress",
|
|
32
|
+
"dry-run",
|
|
33
|
+
"certify",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const LOCATIONS = [
|
|
37
|
+
["BR", "Sao Paulo", -23.55, -46.63],
|
|
38
|
+
["US", "New York", 40.71, -74.01],
|
|
39
|
+
["CA", "Toronto", 43.65, -79.38],
|
|
40
|
+
["GB", "London", 51.51, -0.13],
|
|
41
|
+
["DE", "Berlin", 52.52, 13.41],
|
|
42
|
+
["FR", "Paris", 48.86, 2.35],
|
|
43
|
+
["SE", "Stockholm", 59.33, 18.07],
|
|
44
|
+
["IN", "Bengaluru", 12.97, 77.59],
|
|
45
|
+
["SG", "Singapore", 1.35, 103.82],
|
|
46
|
+
["JP", "Tokyo", 35.68, 139.69],
|
|
47
|
+
["AU", "Sydney", -33.87, 151.21],
|
|
48
|
+
["ZA", "Cape Town", -33.92, 18.42],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const USER_AGENTS = [
|
|
52
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/127.0 Safari/537.36",
|
|
53
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 Version/17.6 Safari/605.1.15",
|
|
54
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 Version/17.6 Mobile/15E148 Safari/604.1",
|
|
55
|
+
"Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const REALISTIC_PATHS = [
|
|
59
|
+
"/products",
|
|
60
|
+
"/docs",
|
|
61
|
+
"/blog/scale",
|
|
62
|
+
"/account",
|
|
63
|
+
"/checkout",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function parseOptions(argv) {
|
|
67
|
+
const values = new Map();
|
|
68
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
69
|
+
const argument = argv[index];
|
|
70
|
+
if (!argument.startsWith("--"))
|
|
71
|
+
throw new Error(`unexpected argument: ${argument}`);
|
|
72
|
+
const [rawName, inlineValue] = argument.slice(2).split("=", 2);
|
|
73
|
+
if (!OPTION_NAMES.has(rawName))
|
|
74
|
+
throw new Error(`unknown option: --${rawName}`);
|
|
75
|
+
if (values.has(rawName)) throw new Error(`duplicate option: --${rawName}`);
|
|
76
|
+
if (rawName === "dry-run" || rawName === "certify") {
|
|
77
|
+
values.set(rawName, "true");
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const value = inlineValue ?? argv[++index];
|
|
81
|
+
if (!value || value.startsWith("--"))
|
|
82
|
+
throw new Error(`missing value for --${rawName}`);
|
|
83
|
+
values.set(rawName, value);
|
|
84
|
+
}
|
|
85
|
+
const profile = values.get("profile") ?? "realistic";
|
|
86
|
+
if (!(profile in PROFILE_DEFAULTS)) {
|
|
87
|
+
throw new Error("--profile must be lean, realistic, heavy, or feature");
|
|
88
|
+
}
|
|
89
|
+
const defaults = PROFILE_DEFAULTS[profile];
|
|
90
|
+
return {
|
|
91
|
+
url: values.get("url"),
|
|
92
|
+
convexUrl: values.get("convex-url"),
|
|
93
|
+
siteId: values.get("site-id"),
|
|
94
|
+
origin: values.get("origin") ?? "https://demo.rastro.local",
|
|
95
|
+
label: values.get("label") ?? `${profile}-${new Date().toISOString()}`,
|
|
96
|
+
profile,
|
|
97
|
+
durationSeconds: positiveNumber(values.get("duration") ?? "60", "duration"),
|
|
98
|
+
maximumRequests: values.has("requests")
|
|
99
|
+
? positiveInteger(values.get("requests"), "requests")
|
|
100
|
+
: Number.POSITIVE_INFINITY,
|
|
101
|
+
concurrency: positiveInteger(
|
|
102
|
+
values.get("concurrency") ?? "100",
|
|
103
|
+
"concurrency",
|
|
104
|
+
),
|
|
105
|
+
batchSize: positiveInteger(
|
|
106
|
+
values.get("batch-size") ?? String(defaults.batchSize),
|
|
107
|
+
"batch-size",
|
|
108
|
+
),
|
|
109
|
+
sessionCount: positiveInteger(
|
|
110
|
+
values.get("sessions") ?? String(defaults.sessions || 1),
|
|
111
|
+
"sessions",
|
|
112
|
+
),
|
|
113
|
+
progressSeconds: positiveNumber(values.get("progress") ?? "10", "progress"),
|
|
114
|
+
dryRun: values.get("dry-run") === "true",
|
|
115
|
+
certify: values.get("certify") === "true",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function positiveNumber(value, name) {
|
|
120
|
+
const parsed = Number(value);
|
|
121
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
122
|
+
throw new Error(`--${name} must be positive`);
|
|
123
|
+
return parsed;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function positiveInteger(value, name) {
|
|
127
|
+
const parsed = positiveNumber(value, name);
|
|
128
|
+
if (!Number.isSafeInteger(parsed))
|
|
129
|
+
throw new Error(`--${name} must be an integer`);
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function eventFor(profile, session, sequence, timestamp) {
|
|
134
|
+
const path =
|
|
135
|
+
profile === "lean"
|
|
136
|
+
? `/benchmark/lean/${sequence % 4}`
|
|
137
|
+
: REALISTIC_PATHS[sequence % REALISTIC_PATHS.length];
|
|
138
|
+
const base = {
|
|
139
|
+
eventId: `${session.id}.${sequence.toString(36)}`,
|
|
140
|
+
sessionId: session.id,
|
|
141
|
+
visitorId: session.visitorId,
|
|
142
|
+
path,
|
|
143
|
+
timestamp,
|
|
144
|
+
sequence,
|
|
145
|
+
};
|
|
146
|
+
if (profile === "lean") return { ...base, type: "pageview" };
|
|
147
|
+
if (profile === "heavy") {
|
|
148
|
+
return {
|
|
149
|
+
...base,
|
|
150
|
+
type: "custom",
|
|
151
|
+
name: "payload-sample",
|
|
152
|
+
properties: Object.fromEntries(
|
|
153
|
+
Array.from({ length: 16 }, (_, index) => [
|
|
154
|
+
`property_${index}`,
|
|
155
|
+
`${index.toString(36)}-${"x".repeat(157)}`,
|
|
156
|
+
]),
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (profile === "feature") {
|
|
161
|
+
const step = sequence % 3;
|
|
162
|
+
if (step === 0) {
|
|
163
|
+
return {
|
|
164
|
+
...base,
|
|
165
|
+
type: "pageview",
|
|
166
|
+
path: "/",
|
|
167
|
+
affiliateSlug: "creator-club",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (step === 1) {
|
|
171
|
+
return {
|
|
172
|
+
...base,
|
|
173
|
+
type: "pageview",
|
|
174
|
+
path: "/pricing",
|
|
175
|
+
affiliateSlug: "creator-club",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
...base,
|
|
180
|
+
type: "custom",
|
|
181
|
+
name: "signup",
|
|
182
|
+
path: "/signup",
|
|
183
|
+
affiliateSlug: "creator-club",
|
|
184
|
+
properties: { plan: sequence % 2 === 0 ? "pro" : "starter" },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const kind = sequence === 0 ? 0 : sequence % 20;
|
|
189
|
+
if (kind === 19) return { ...base, type: "heartbeat" };
|
|
190
|
+
if (kind < 6) {
|
|
191
|
+
return {
|
|
192
|
+
...base,
|
|
193
|
+
type: "pageview",
|
|
194
|
+
referrer:
|
|
195
|
+
sequence % 2 === 0
|
|
196
|
+
? "https://www.google.com/search?q=rastro"
|
|
197
|
+
: undefined,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (kind < 13) return { ...base, type: "click", target: "Get started" };
|
|
201
|
+
if (kind < 17) {
|
|
202
|
+
return {
|
|
203
|
+
...base,
|
|
204
|
+
type: "custom",
|
|
205
|
+
name: "cta-engaged",
|
|
206
|
+
properties: { placement: "hero", experiment: `variant-${sequence % 3}` },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
...base,
|
|
211
|
+
type: "outbound",
|
|
212
|
+
href: "https://github.com/amageweb/amage-rastro",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function requestContext(session) {
|
|
217
|
+
const [country, city, latitude, longitude] = LOCATIONS[session.locationIndex];
|
|
218
|
+
return {
|
|
219
|
+
"User-Agent": USER_AGENTS[session.userAgentIndex],
|
|
220
|
+
"x-vercel-ip-country": country,
|
|
221
|
+
"x-vercel-ip-city": encodeURIComponent(city),
|
|
222
|
+
"x-vercel-ip-latitude": String(latitude),
|
|
223
|
+
"x-vercel-ip-longitude": String(longitude),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function percentile(sorted, fraction) {
|
|
228
|
+
if (sorted.length === 0) return 0;
|
|
229
|
+
return sorted[
|
|
230
|
+
Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function hasValidSessionTotals(value) {
|
|
235
|
+
return (
|
|
236
|
+
value !== null &&
|
|
237
|
+
typeof value === "object" &&
|
|
238
|
+
[value.requestedSessions, value.foundSessions, value.storedEvents].every(
|
|
239
|
+
(total) => Number.isSafeInteger(total) && total >= 0,
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function main() {
|
|
245
|
+
const options = parseOptions(process.argv.slice(2));
|
|
246
|
+
if (options.batchSize > 50) throw new Error("--batch-size cannot exceed 50");
|
|
247
|
+
if (options.certify && options.durationSeconds < 600) {
|
|
248
|
+
throw new Error("--certify requires --duration of at least 600 seconds");
|
|
249
|
+
}
|
|
250
|
+
if (options.certify && !options.convexUrl) {
|
|
251
|
+
throw new Error("--certify requires --convex-url reconciliation");
|
|
252
|
+
}
|
|
253
|
+
if (options.certify && Number.isFinite(options.maximumRequests)) {
|
|
254
|
+
throw new Error("--certify cannot be combined with --requests");
|
|
255
|
+
}
|
|
256
|
+
const runId = randomUUID();
|
|
257
|
+
const sessions = Array.from({ length: options.sessionCount }, (_, index) => ({
|
|
258
|
+
id: `bench-${runId}-${index}`,
|
|
259
|
+
visitorId: `visitor-${runId}-${index}`,
|
|
260
|
+
sequence: 0,
|
|
261
|
+
locationIndex: index % LOCATIONS.length,
|
|
262
|
+
userAgentIndex: index % USER_AGENTS.length,
|
|
263
|
+
}));
|
|
264
|
+
let nextRequest = 0;
|
|
265
|
+
let stopping = false;
|
|
266
|
+
let terminationReason = null;
|
|
267
|
+
let accepted = 0;
|
|
268
|
+
let duplicates = 0;
|
|
269
|
+
let rejected = 0;
|
|
270
|
+
let failures = 0;
|
|
271
|
+
let failedEvents = 0;
|
|
272
|
+
let completedRequests = 0;
|
|
273
|
+
let sentEvents = 0;
|
|
274
|
+
let sentHeartbeats = 0;
|
|
275
|
+
let sentBytes = 0;
|
|
276
|
+
let acceptedHeartbeats = 0;
|
|
277
|
+
let reconciliationReliable = true;
|
|
278
|
+
const errors = new Map();
|
|
279
|
+
const durations = [];
|
|
280
|
+
|
|
281
|
+
const buildRequest = (requestIndex) => {
|
|
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];
|
|
292
|
+
const sequenceStart = session.sequence;
|
|
293
|
+
session.sequence += options.batchSize;
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
const events = Array.from({ length: options.batchSize }, (_, index) =>
|
|
296
|
+
eventFor(options.profile, session, sequenceStart + index, now + index),
|
|
297
|
+
);
|
|
298
|
+
return {
|
|
299
|
+
body: JSON.stringify({ siteId: options.siteId, events }),
|
|
300
|
+
context: requestContext(session),
|
|
301
|
+
heartbeats: events.filter((event) => event.type === "heartbeat").length,
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const dryRequest = buildRequest(0);
|
|
306
|
+
if (Buffer.byteLength(dryRequest.body) > 64 * 1024) {
|
|
307
|
+
throw new Error("generated request exceeds the 64 KiB HTTP limit");
|
|
308
|
+
}
|
|
309
|
+
if (options.dryRun) {
|
|
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
|
+
);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (options.profile !== "lean") sessions[0].sequence = 0;
|
|
326
|
+
if (!options.url || !options.siteId) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
"--url and --site-id are required unless --dry-run is used",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const stopForSignal = (signal) => {
|
|
333
|
+
stopping = true;
|
|
334
|
+
terminationReason = signal;
|
|
335
|
+
};
|
|
336
|
+
const stopForInterrupt = () => stopForSignal("SIGINT");
|
|
337
|
+
const stopForTermination = () => stopForSignal("SIGTERM");
|
|
338
|
+
process.once("SIGINT", stopForInterrupt);
|
|
339
|
+
process.once("SIGTERM", stopForTermination);
|
|
340
|
+
|
|
341
|
+
const convex = options.convexUrl
|
|
342
|
+
? new ConvexHttpClient(options.convexUrl)
|
|
343
|
+
: null;
|
|
344
|
+
if (convex) {
|
|
345
|
+
const preflight = await convex.query(reconcileSessionsQuery, {
|
|
346
|
+
siteId: options.siteId,
|
|
347
|
+
runId,
|
|
348
|
+
start: 0,
|
|
349
|
+
count: 1,
|
|
350
|
+
});
|
|
351
|
+
if (
|
|
352
|
+
!hasValidSessionTotals(preflight) ||
|
|
353
|
+
preflight.requestedSessions !== 1 ||
|
|
354
|
+
preflight.foundSessions !== 0 ||
|
|
355
|
+
preflight.storedEvents !== 0
|
|
356
|
+
) {
|
|
357
|
+
throw new Error("reconciliation preflight returned unexpected totals");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const startedAt = performance.now();
|
|
361
|
+
const deadline = startedAt + options.durationSeconds * 1_000;
|
|
362
|
+
const recordError = (key) => errors.set(key, (errors.get(key) ?? 0) + 1);
|
|
363
|
+
const worker = async () => {
|
|
364
|
+
while (!stopping) {
|
|
365
|
+
if (nextRequest >= options.maximumRequests) {
|
|
366
|
+
terminationReason ??= "request-limit";
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (performance.now() >= deadline) {
|
|
370
|
+
terminationReason ??= "duration";
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const requestIndex = nextRequest++;
|
|
374
|
+
const request = buildRequest(requestIndex);
|
|
375
|
+
const requestBytes = Buffer.byteLength(request.body);
|
|
376
|
+
if (requestBytes > 64 * 1024) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`generated request ${requestIndex} exceeds the 64 KiB HTTP limit`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
sentBytes += requestBytes;
|
|
382
|
+
sentEvents += options.batchSize;
|
|
383
|
+
sentHeartbeats += request.heartbeats;
|
|
384
|
+
const requestStartedAt = performance.now();
|
|
385
|
+
try {
|
|
386
|
+
const response = await globalThis.fetch(options.url, {
|
|
387
|
+
method: "POST",
|
|
388
|
+
headers: {
|
|
389
|
+
"Content-Type": "application/json",
|
|
390
|
+
Origin: options.origin,
|
|
391
|
+
...request.context,
|
|
392
|
+
},
|
|
393
|
+
body: request.body,
|
|
394
|
+
signal: globalThis.AbortSignal.timeout(30_000),
|
|
395
|
+
});
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
failures += 1;
|
|
398
|
+
failedEvents += options.batchSize;
|
|
399
|
+
const body = await response.text();
|
|
400
|
+
let code = body.slice(0, 160);
|
|
401
|
+
try {
|
|
402
|
+
const parsed = JSON.parse(body);
|
|
403
|
+
code = parsed?.error?.code ?? parsed?.error?.message ?? code;
|
|
404
|
+
} catch {
|
|
405
|
+
// Preserve the bounded response text.
|
|
406
|
+
}
|
|
407
|
+
recordError(`${response.status}:${code}`);
|
|
408
|
+
} else {
|
|
409
|
+
const result = await response.json();
|
|
410
|
+
if (
|
|
411
|
+
![result.accepted, result.duplicates, result.rejected].every(
|
|
412
|
+
(value) => Number.isSafeInteger(value) && value >= 0,
|
|
413
|
+
) ||
|
|
414
|
+
result.accepted + result.duplicates + result.rejected !==
|
|
415
|
+
options.batchSize
|
|
416
|
+
) {
|
|
417
|
+
throw new Error("response has invalid event accounting");
|
|
418
|
+
}
|
|
419
|
+
accepted += result.accepted;
|
|
420
|
+
duplicates += result.duplicates;
|
|
421
|
+
rejected += result.rejected;
|
|
422
|
+
if (result.accepted === options.batchSize) {
|
|
423
|
+
acceptedHeartbeats += request.heartbeats;
|
|
424
|
+
} else {
|
|
425
|
+
reconciliationReliable = false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
failures += 1;
|
|
430
|
+
failedEvents += options.batchSize;
|
|
431
|
+
recordError(
|
|
432
|
+
`fetch:${error instanceof Error ? error.message : "unknown"}`,
|
|
433
|
+
);
|
|
434
|
+
} finally {
|
|
435
|
+
durations.push(performance.now() - requestStartedAt);
|
|
436
|
+
completedRequests += 1;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const progress = setInterval(() => {
|
|
442
|
+
const elapsedSeconds = Math.max(
|
|
443
|
+
0.001,
|
|
444
|
+
(performance.now() - startedAt) / 1_000,
|
|
445
|
+
);
|
|
446
|
+
process.stderr.write(
|
|
447
|
+
`[${options.label}] ${Math.round(elapsedSeconds)}s ` +
|
|
448
|
+
`${accepted} accepted ${Math.round(accepted / elapsedSeconds)} events/s ` +
|
|
449
|
+
`${failures} failures\n`,
|
|
450
|
+
);
|
|
451
|
+
}, options.progressSeconds * 1_000);
|
|
452
|
+
progress.unref();
|
|
453
|
+
await Promise.all(Array.from({ length: options.concurrency }, worker));
|
|
454
|
+
clearInterval(progress);
|
|
455
|
+
|
|
456
|
+
const elapsedMs = performance.now() - startedAt;
|
|
457
|
+
terminationReason ??=
|
|
458
|
+
performance.now() >= deadline ? "duration" : "completed";
|
|
459
|
+
process.removeListener("SIGINT", stopForInterrupt);
|
|
460
|
+
process.removeListener("SIGTERM", stopForTermination);
|
|
461
|
+
const durationCompleted = terminationReason === "duration";
|
|
462
|
+
durations.sort((left, right) => left - right);
|
|
463
|
+
const expectedSessions =
|
|
464
|
+
options.profile === "lean"
|
|
465
|
+
? nextRequest
|
|
466
|
+
: Math.min(nextRequest, options.sessionCount);
|
|
467
|
+
let sessionTotals = null;
|
|
468
|
+
if (convex && expectedSessions > 0) {
|
|
469
|
+
try {
|
|
470
|
+
sessionTotals = {
|
|
471
|
+
requestedSessions: 0,
|
|
472
|
+
foundSessions: 0,
|
|
473
|
+
storedEvents: 0,
|
|
474
|
+
};
|
|
475
|
+
for (let start = 0; start < expectedSessions; start += 100) {
|
|
476
|
+
const page = await convex.query(reconcileSessionsQuery, {
|
|
477
|
+
siteId: options.siteId,
|
|
478
|
+
runId,
|
|
479
|
+
start,
|
|
480
|
+
count: Math.min(100, expectedSessions - start),
|
|
481
|
+
});
|
|
482
|
+
if (!hasValidSessionTotals(page)) {
|
|
483
|
+
throw new Error("reconciliation response has invalid totals");
|
|
484
|
+
}
|
|
485
|
+
sessionTotals.requestedSessions += page.requestedSessions;
|
|
486
|
+
sessionTotals.foundSessions += page.foundSessions;
|
|
487
|
+
sessionTotals.storedEvents += page.storedEvents;
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
reconciliationReliable = false;
|
|
491
|
+
sessionTotals = null;
|
|
492
|
+
recordError(
|
|
493
|
+
`reconciliation:${error instanceof Error ? error.message : "unknown"}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const expectedStoredEvents = reconciliationReliable
|
|
498
|
+
? accepted - acceptedHeartbeats
|
|
499
|
+
: null;
|
|
500
|
+
const storedEvents = sessionTotals?.storedEvents ?? expectedStoredEvents;
|
|
501
|
+
const reconciliation = sessionTotals
|
|
502
|
+
? {
|
|
503
|
+
scope: "run-session-ids",
|
|
504
|
+
reliable: reconciliationReliable && failures === 0 && rejected === 0,
|
|
505
|
+
expectedSessions,
|
|
506
|
+
requestedSessions: sessionTotals.requestedSessions,
|
|
507
|
+
foundSessions: sessionTotals.foundSessions,
|
|
508
|
+
acceptedEvents: accepted,
|
|
509
|
+
acceptedHeartbeats: reconciliationReliable ? acceptedHeartbeats : null,
|
|
510
|
+
expectedStoredEvents,
|
|
511
|
+
storedEvents: sessionTotals.storedEvents,
|
|
512
|
+
matches:
|
|
513
|
+
reconciliationReliable &&
|
|
514
|
+
failures === 0 &&
|
|
515
|
+
rejected === 0 &&
|
|
516
|
+
expectedStoredEvents !== null &&
|
|
517
|
+
sessionTotals.requestedSessions === expectedSessions &&
|
|
518
|
+
sessionTotals.foundSessions === expectedSessions &&
|
|
519
|
+
sessionTotals.storedEvents === expectedStoredEvents,
|
|
520
|
+
}
|
|
521
|
+
: null;
|
|
522
|
+
const cleanRun =
|
|
523
|
+
failures === 0 && duplicates === 0 && rejected === 0 && durationCompleted;
|
|
524
|
+
const reconciliationPassed = Boolean(
|
|
525
|
+
reconciliation?.reliable && reconciliation.matches,
|
|
526
|
+
);
|
|
527
|
+
const certificationPassed =
|
|
528
|
+
options.certify && cleanRun && reconciliationPassed;
|
|
529
|
+
const result = {
|
|
530
|
+
label: options.label,
|
|
531
|
+
runId,
|
|
532
|
+
profile: options.profile,
|
|
533
|
+
targetDurationSeconds: options.durationSeconds,
|
|
534
|
+
elapsedMs: Math.round(elapsedMs),
|
|
535
|
+
terminationReason,
|
|
536
|
+
durationCompleted,
|
|
537
|
+
certification: {
|
|
538
|
+
requested: options.certify,
|
|
539
|
+
passed: certificationPassed,
|
|
540
|
+
minimumDurationSeconds: 600,
|
|
541
|
+
},
|
|
542
|
+
concurrency: options.concurrency,
|
|
543
|
+
batchSize: options.batchSize,
|
|
544
|
+
sessionPool:
|
|
545
|
+
options.profile === "lean" ? "unique-per-request" : options.sessionCount,
|
|
546
|
+
maximumRequests: Number.isFinite(options.maximumRequests)
|
|
547
|
+
? options.maximumRequests
|
|
548
|
+
: null,
|
|
549
|
+
requests: { started: nextRequest, completed: completedRequests, failures },
|
|
550
|
+
events: {
|
|
551
|
+
sent: sentEvents,
|
|
552
|
+
heartbeats: sentHeartbeats,
|
|
553
|
+
accepted,
|
|
554
|
+
stored: storedEvents,
|
|
555
|
+
duplicates,
|
|
556
|
+
rejected,
|
|
557
|
+
failed: failedEvents,
|
|
558
|
+
},
|
|
559
|
+
errors: Object.fromEntries(errors),
|
|
560
|
+
throughput: {
|
|
561
|
+
eventsPerSecond: Math.round((accepted * 1_000) / elapsedMs),
|
|
562
|
+
storedEventsPerSecond: reconciliationPassed
|
|
563
|
+
? Math.round((storedEvents * 1_000) / elapsedMs)
|
|
564
|
+
: null,
|
|
565
|
+
estimatedEventsPerDay: durationCompleted
|
|
566
|
+
? Math.round((accepted * 86_400_000) / elapsedMs)
|
|
567
|
+
: null,
|
|
568
|
+
estimatedStoredEventsPerDay:
|
|
569
|
+
durationCompleted && reconciliationPassed
|
|
570
|
+
? Math.round((storedEvents * 86_400_000) / elapsedMs)
|
|
571
|
+
: null,
|
|
572
|
+
certifiedEventsPerDay: certificationPassed
|
|
573
|
+
? Math.round((accepted * 86_400_000) / elapsedMs)
|
|
574
|
+
: null,
|
|
575
|
+
certifiedStoredEventsPerDay: certificationPassed
|
|
576
|
+
? Math.round((storedEvents * 86_400_000) / elapsedMs)
|
|
577
|
+
: null,
|
|
578
|
+
requestBytesPerSecond: Math.round((sentBytes * 1_000) / elapsedMs),
|
|
579
|
+
},
|
|
580
|
+
latencyMs: {
|
|
581
|
+
p50: Math.round(percentile(durations, 0.5)),
|
|
582
|
+
p95: Math.round(percentile(durations, 0.95)),
|
|
583
|
+
p99: Math.round(percentile(durations, 0.99)),
|
|
584
|
+
max: Math.round(durations.at(-1) ?? 0),
|
|
585
|
+
},
|
|
586
|
+
reconciliation,
|
|
587
|
+
};
|
|
588
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
589
|
+
if (
|
|
590
|
+
failures > 0 ||
|
|
591
|
+
duplicates > 0 ||
|
|
592
|
+
rejected > 0 ||
|
|
593
|
+
!durationCompleted ||
|
|
594
|
+
(options.convexUrl && !reconciliationPassed) ||
|
|
595
|
+
(options.certify && !certificationPassed)
|
|
596
|
+
) {
|
|
597
|
+
process.exitCode = 1;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
await main();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
|
|
4
|
+
|
|
5
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", {
|
|
6
|
+
extractable: true,
|
|
7
|
+
});
|
|
8
|
+
const publicJwk = await exportJWK(publicKey);
|
|
9
|
+
|
|
10
|
+
process.stdout.write(
|
|
11
|
+
JSON.stringify(
|
|
12
|
+
{
|
|
13
|
+
keyId: `rastro-${randomUUID()}`,
|
|
14
|
+
privateKeyPem: await exportPKCS8(privateKey),
|
|
15
|
+
publicJwk,
|
|
16
|
+
},
|
|
17
|
+
null,
|
|
18
|
+
2,
|
|
19
|
+
) + "\n",
|
|
20
|
+
);
|
|
@@ -9,19 +9,28 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type * as affiliates from "../affiliates.js";
|
|
12
|
+
import type * as cardinality from "../cardinality.js";
|
|
12
13
|
import type * as constants from "../constants.js";
|
|
14
|
+
import type * as coverage from "../coverage.js";
|
|
15
|
+
import type * as diagnostics from "../diagnostics.js";
|
|
13
16
|
import type * as errors from "../errors.js";
|
|
17
|
+
import type * as eventStore from "../eventStore.js";
|
|
14
18
|
import type * as funnels from "../funnels.js";
|
|
19
|
+
import type * as geo from "../geo.js";
|
|
15
20
|
import type * as goals from "../goals.js";
|
|
16
21
|
import type * as guards from "../guards.js";
|
|
17
22
|
import type * as http from "../http.js";
|
|
23
|
+
import type * as identity from "../identity.js";
|
|
18
24
|
import type * as ingest from "../ingest.js";
|
|
19
25
|
import type * as live from "../live.js";
|
|
20
26
|
import type * as reports from "../reports.js";
|
|
21
27
|
import type * as retention from "../retention.js";
|
|
22
28
|
import type * as sanitize from "../sanitize.js";
|
|
23
29
|
import type * as sites from "../sites.js";
|
|
30
|
+
import type * as useragent from "../useragent.js";
|
|
24
31
|
import type * as validators from "../validators.js";
|
|
32
|
+
import type * as visitors from "../visitors.js";
|
|
33
|
+
import type * as vitals from "../vitals.js";
|
|
25
34
|
|
|
26
35
|
import type {
|
|
27
36
|
ApiFromModules,
|
|
@@ -32,19 +41,28 @@ import { anyApi, componentsGeneric } from "convex/server";
|
|
|
32
41
|
|
|
33
42
|
const fullApi: ApiFromModules<{
|
|
34
43
|
affiliates: typeof affiliates;
|
|
44
|
+
cardinality: typeof cardinality;
|
|
35
45
|
constants: typeof constants;
|
|
46
|
+
coverage: typeof coverage;
|
|
47
|
+
diagnostics: typeof diagnostics;
|
|
36
48
|
errors: typeof errors;
|
|
49
|
+
eventStore: typeof eventStore;
|
|
37
50
|
funnels: typeof funnels;
|
|
51
|
+
geo: typeof geo;
|
|
38
52
|
goals: typeof goals;
|
|
39
53
|
guards: typeof guards;
|
|
40
54
|
http: typeof http;
|
|
55
|
+
identity: typeof identity;
|
|
41
56
|
ingest: typeof ingest;
|
|
42
57
|
live: typeof live;
|
|
43
58
|
reports: typeof reports;
|
|
44
59
|
retention: typeof retention;
|
|
45
60
|
sanitize: typeof sanitize;
|
|
46
61
|
sites: typeof sites;
|
|
62
|
+
useragent: typeof useragent;
|
|
47
63
|
validators: typeof validators;
|
|
64
|
+
visitors: typeof visitors;
|
|
65
|
+
vitals: typeof vitals;
|
|
48
66
|
}> = anyApi as any;
|
|
49
67
|
|
|
50
68
|
/**
|