@iann29/rastro 0.1.0-alpha.5 → 0.1.0-alpha.7
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 +82 -29
- package/agent/integration.md +17 -15
- package/agent/manifest.json +7 -5
- package/agent/manifest.schema.json +8 -5
- package/dist/client/federation.d.ts +34 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +17 -3
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +418 -21
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +191 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -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 +63 -7
- package/dist/component/_generated/component.d.ts.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.map +1 -1
- package/dist/component/cardinality.js.map +1 -1
- package/dist/component/constants.d.ts +7 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +13 -1
- package/dist/component/constants.js.map +1 -1
- package/dist/component/diagnostics.d.ts.map +1 -1
- package/dist/component/diagnostics.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -1
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +13 -10
- 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.map +1 -1
- package/dist/component/geo.js +3 -2
- package/dist/component/geo.js.map +1 -1
- 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 +51 -17
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts +2 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +143 -23
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +1 -3
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +60 -3
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +215 -49
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +4 -4
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +54 -22
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +11 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +58 -11
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +47 -5
- package/dist/component/schema.js +21 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +6 -6
- package/dist/component/sites.js.map +1 -1
- package/dist/component/validators.d.ts +14 -9
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +5 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts.map +1 -1
- package/dist/component/visitors.js.map +1 -1
- 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.map +1 -1
- package/dist/tracker/tracker.js +3 -1
- 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 +71 -71
- package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
- package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
- package/docs/upgrading.md +46 -8
- package/llms.txt +5 -1
- package/package.json +17 -3
- package/scripts/benchmark-ingest.mjs +101 -48
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +69 -4
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +8 -7
- package/src/component/constants.ts +13 -1
- package/src/component/diagnostics.ts +3 -2
- package/src/component/eventStore.ts +53 -46
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +16 -14
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +93 -54
- package/src/component/ingest.ts +320 -127
- package/src/component/live.ts +5 -4
- package/src/component/reports.ts +563 -219
- package/src/component/retention.ts +221 -98
- package/src/component/sanitize.ts +99 -29
- package/src/component/schema.ts +31 -5
- package/src/component/sites.ts +22 -11
- package/src/component/validators.ts +16 -7
- package/src/component/visitors.ts +16 -5
- package/src/component/vitals.ts +146 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CLOCK_SKEW_TOLERANCE_MS,
|
|
2
3
|
MAX_BATCH_BYTES,
|
|
3
4
|
MAX_PROPERTIES,
|
|
4
5
|
MAX_SITE_DOMAINS,
|
|
@@ -8,6 +9,7 @@ import type {
|
|
|
8
9
|
IngestContext,
|
|
9
10
|
TrackerEvent,
|
|
10
11
|
} from "./validators.js";
|
|
12
|
+
import { isValidVitalValue, isVitalMetric } from "./vitals.js";
|
|
11
13
|
|
|
12
14
|
const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
13
15
|
const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
|
|
@@ -62,9 +64,10 @@ export function sanitizePath(value: string): string {
|
|
|
62
64
|
const input = requireString(value, "path", 2048);
|
|
63
65
|
let path: string;
|
|
64
66
|
try {
|
|
65
|
-
path =
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
path =
|
|
68
|
+
input.startsWith("http://") || input.startsWith("https://")
|
|
69
|
+
? new URL(input).pathname
|
|
70
|
+
: input.split(/[?#]/, 1)[0];
|
|
68
71
|
} catch {
|
|
69
72
|
throw new Error("path is invalid");
|
|
70
73
|
}
|
|
@@ -111,15 +114,42 @@ export function sanitizeProperties(
|
|
|
111
114
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Re-anchors a batch whose sender's clock disagrees with ours. `sentAt` is the
|
|
119
|
+
* sender's clock at flush time, so `now - sentAt` is the clock offset alone: an
|
|
120
|
+
* event queued long before the flush keeps its age, and nothing is inferred
|
|
121
|
+
* from the events themselves. Offsets inside the tolerance are latency or a
|
|
122
|
+
* slightly drifted clock and leave the batch untouched, so a well-set client's
|
|
123
|
+
* timestamps stay exactly as sent.
|
|
124
|
+
*/
|
|
125
|
+
export function alignClock<T extends { timestamp: number }>(
|
|
126
|
+
events: T[],
|
|
127
|
+
sentAt: number | undefined,
|
|
128
|
+
now: number,
|
|
129
|
+
): T[] {
|
|
130
|
+
if (sentAt === undefined || !Number.isFinite(sentAt)) return events;
|
|
131
|
+
const offset = now - sentAt;
|
|
132
|
+
if (Math.abs(offset) <= CLOCK_SKEW_TOLERANCE_MS) return events;
|
|
133
|
+
return events.map((event) => ({
|
|
134
|
+
...event,
|
|
135
|
+
timestamp: event.timestamp + offset,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
114
139
|
export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
|
|
115
|
-
if (!Number.isFinite(event.timestamp))
|
|
140
|
+
if (!Number.isFinite(event.timestamp))
|
|
141
|
+
throw new Error("timestamp is invalid");
|
|
116
142
|
if (
|
|
117
143
|
event.timestamp < now - MAX_EVENT_AGE_MS ||
|
|
118
144
|
event.timestamp > now + MAX_FUTURE_SKEW_MS
|
|
119
145
|
) {
|
|
120
146
|
throw new Error("timestamp is outside the accepted window");
|
|
121
147
|
}
|
|
122
|
-
if (
|
|
148
|
+
if (
|
|
149
|
+
!Number.isSafeInteger(event.sequence) ||
|
|
150
|
+
event.sequence < 0 ||
|
|
151
|
+
event.sequence > 1_000_000_000
|
|
152
|
+
) {
|
|
123
153
|
throw new Error("sequence is out of range");
|
|
124
154
|
}
|
|
125
155
|
if (event.revenueCents !== undefined && !isValidMoney(event.revenueCents)) {
|
|
@@ -130,9 +160,20 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
|
|
|
130
160
|
throw new Error("name is required for custom events");
|
|
131
161
|
}
|
|
132
162
|
if (event.type === "conversion" && !name) name = "conversion";
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
163
|
+
if (event.type === "vital") {
|
|
164
|
+
if (!name || !isVitalMetric(name)) {
|
|
165
|
+
throw new Error("vital name must be a Web Vitals metric");
|
|
166
|
+
}
|
|
167
|
+
if (event.value === undefined || !isValidVitalValue(event.value)) {
|
|
168
|
+
throw new Error("vital value is out of range");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const currency =
|
|
172
|
+
event.currency === undefined
|
|
173
|
+
? event.type === "conversion"
|
|
174
|
+
? "USD"
|
|
175
|
+
: undefined
|
|
176
|
+
: sanitizeCurrency(event.currency);
|
|
136
177
|
return {
|
|
137
178
|
eventId: sanitizeOpaqueId(event.eventId, "eventId"),
|
|
138
179
|
sessionId: sanitizeOpaqueId(event.sessionId, "sessionId"),
|
|
@@ -140,19 +181,26 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
|
|
|
140
181
|
type: event.type,
|
|
141
182
|
name,
|
|
142
183
|
path: sanitizePath(event.path),
|
|
143
|
-
referrer:
|
|
144
|
-
|
|
145
|
-
|
|
184
|
+
referrer:
|
|
185
|
+
event.referrer === undefined
|
|
186
|
+
? undefined
|
|
187
|
+
: sanitizePublicUrl(event.referrer),
|
|
146
188
|
timestamp: Math.trunc(event.timestamp),
|
|
147
189
|
sequence: event.sequence,
|
|
148
|
-
target:
|
|
149
|
-
|
|
150
|
-
|
|
190
|
+
target:
|
|
191
|
+
event.target === undefined
|
|
192
|
+
? undefined
|
|
193
|
+
: cleanString(event.target.replace(/\s+/g, " "), 160) || undefined,
|
|
151
194
|
href: event.href === undefined ? undefined : sanitizePublicUrl(event.href),
|
|
152
195
|
properties: sanitizeProperties(event.properties),
|
|
153
196
|
revenueCents: event.revenueCents,
|
|
154
197
|
currency,
|
|
155
198
|
affiliateSlug: optionalAffiliateSlug(event.affiliateSlug),
|
|
199
|
+
// Only measurements keep a value; a stray number on any other type is noise.
|
|
200
|
+
value:
|
|
201
|
+
event.type === "vital" && event.value !== undefined
|
|
202
|
+
? Math.round(event.value)
|
|
203
|
+
: undefined,
|
|
156
204
|
};
|
|
157
205
|
}
|
|
158
206
|
|
|
@@ -167,7 +215,9 @@ function optionalAffiliateSlug(value: string | undefined): string | undefined {
|
|
|
167
215
|
|
|
168
216
|
export function normalizeDomains(domains: string[]): string[] {
|
|
169
217
|
if (domains.length === 0 || domains.length > MAX_SITE_DOMAINS) {
|
|
170
|
-
throw new Error(
|
|
218
|
+
throw new Error(
|
|
219
|
+
`domains must contain between 1 and ${MAX_SITE_DOMAINS} entries`,
|
|
220
|
+
);
|
|
171
221
|
}
|
|
172
222
|
const normalized = domains.map(normalizeDomainPattern);
|
|
173
223
|
return [...new Set(normalized)].sort();
|
|
@@ -185,7 +235,11 @@ export function normalizeDomainPattern(value: string): string {
|
|
|
185
235
|
throw new Error("domains cannot include paths or query strings");
|
|
186
236
|
}
|
|
187
237
|
hostname = url.hostname;
|
|
188
|
-
} else if (
|
|
238
|
+
} else if (
|
|
239
|
+
hostInput.includes("/") ||
|
|
240
|
+
hostInput.includes("?") ||
|
|
241
|
+
hostInput.includes("#")
|
|
242
|
+
) {
|
|
189
243
|
throw new Error("domain is invalid");
|
|
190
244
|
}
|
|
191
245
|
hostname = hostname.replace(/\.$/, "");
|
|
@@ -214,7 +268,9 @@ export function originAllowed(origin: string, domains: string[]): boolean {
|
|
|
214
268
|
});
|
|
215
269
|
}
|
|
216
270
|
|
|
217
|
-
export function validateTimezone(
|
|
271
|
+
export function validateTimezone(
|
|
272
|
+
value: string | undefined,
|
|
273
|
+
): string | undefined {
|
|
218
274
|
if (value === undefined || value.trim() === "") return undefined;
|
|
219
275
|
const timezone = cleanString(value, 80);
|
|
220
276
|
try {
|
|
@@ -225,20 +281,33 @@ export function validateTimezone(value: string | undefined): string | undefined
|
|
|
225
281
|
}
|
|
226
282
|
}
|
|
227
283
|
|
|
228
|
-
export function sanitizeContext(
|
|
284
|
+
export function sanitizeContext(
|
|
285
|
+
context: IngestContext | undefined,
|
|
286
|
+
): IngestContext {
|
|
229
287
|
const latitude = finiteCoordinate(context?.latitude, -90, 90);
|
|
230
288
|
const longitude = finiteCoordinate(context?.longitude, -180, 180);
|
|
231
289
|
return {
|
|
232
|
-
country: context?.country
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
290
|
+
country: context?.country
|
|
291
|
+
? cleanString(context.country.toUpperCase(), 2) || undefined
|
|
292
|
+
: undefined,
|
|
293
|
+
city: context?.city
|
|
294
|
+
? cleanString(context.city, 80) || undefined
|
|
295
|
+
: undefined,
|
|
296
|
+
latitude:
|
|
297
|
+
latitude === undefined ? undefined : Math.round(latitude * 10) / 10,
|
|
298
|
+
longitude:
|
|
299
|
+
longitude === undefined ? undefined : Math.round(longitude * 10) / 10,
|
|
300
|
+
browser: context?.browser
|
|
301
|
+
? cleanString(context.browser, 32) || undefined
|
|
302
|
+
: undefined,
|
|
237
303
|
os: context?.os ? cleanString(context.os, 32) || undefined : undefined,
|
|
238
|
-
device: context?.device
|
|
239
|
-
|
|
240
|
-
? cleanString(context.visitorKey, 128) || undefined
|
|
304
|
+
device: context?.device
|
|
305
|
+
? cleanString(context.device, 24) || undefined
|
|
241
306
|
: undefined,
|
|
307
|
+
visitorKey:
|
|
308
|
+
context?.visitorKey && SAFE_OPAQUE_ID.test(context.visitorKey)
|
|
309
|
+
? cleanString(context.visitorKey, 128) || undefined
|
|
310
|
+
: undefined,
|
|
242
311
|
};
|
|
243
312
|
}
|
|
244
313
|
|
|
@@ -257,8 +326,8 @@ export function measureBatchBytes(
|
|
|
257
326
|
events: TrackerEvent[],
|
|
258
327
|
): { batchBytes: number; eventByteCounts: number[] } {
|
|
259
328
|
const encoder = new TextEncoder();
|
|
260
|
-
const eventByteCounts = events.map(
|
|
261
|
-
encoder.encode(JSON.stringify(event)).byteLength
|
|
329
|
+
const eventByteCounts = events.map(
|
|
330
|
+
(event) => encoder.encode(JSON.stringify(event)).byteLength,
|
|
262
331
|
);
|
|
263
332
|
// The empty envelope already includes both array brackets; only commas remain.
|
|
264
333
|
const batchBytes =
|
|
@@ -294,6 +363,7 @@ function finiteCoordinate(
|
|
|
294
363
|
maximum: number,
|
|
295
364
|
): number | undefined {
|
|
296
365
|
if (value === undefined) return undefined;
|
|
297
|
-
if (!Number.isFinite(value) || value < minimum || value > maximum)
|
|
366
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum)
|
|
367
|
+
return undefined;
|
|
298
368
|
return value;
|
|
299
369
|
}
|
package/src/component/schema.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
sessionFieldsValidator,
|
|
10
10
|
siteFieldsValidator,
|
|
11
11
|
visitorSketchValidator,
|
|
12
|
+
vitalMetricValidator,
|
|
12
13
|
} from "./validators.js";
|
|
13
14
|
|
|
14
15
|
export default defineSchema({
|
|
@@ -131,11 +132,13 @@ export default defineSchema({
|
|
|
131
132
|
bucketStart: v.number(),
|
|
132
133
|
shard: v.number(),
|
|
133
134
|
generation: v.literal(1),
|
|
134
|
-
steps: v.array(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
steps: v.array(
|
|
136
|
+
v.object({
|
|
137
|
+
step: v.number(),
|
|
138
|
+
entrants: v.number(),
|
|
139
|
+
completions: v.number(),
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
139
142
|
})
|
|
140
143
|
.index("by_siteId_and_funnelId_and_bucketStart_and_shard", [
|
|
141
144
|
"siteId",
|
|
@@ -150,6 +153,29 @@ export default defineSchema({
|
|
|
150
153
|
])
|
|
151
154
|
.index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
|
|
152
155
|
|
|
156
|
+
// Field Web Vitals. One row per site/day/shard and either a page (device
|
|
157
|
+
// "(all)") or a device class (page "(all)"); the row folds every metric's
|
|
158
|
+
// histogram together so a report reads five metrics for one document. Pages
|
|
159
|
+
// are bounded like dimension slots: at most VITAL_PAGE_SLOTS distinct pages
|
|
160
|
+
// per day, then "(other)".
|
|
161
|
+
vitalRollups: defineTable({
|
|
162
|
+
siteId: v.id("sites"),
|
|
163
|
+
bucketStart: v.number(),
|
|
164
|
+
device: v.string(),
|
|
165
|
+
page: v.string(),
|
|
166
|
+
shard: v.number(),
|
|
167
|
+
metrics: v.array(
|
|
168
|
+
v.object({
|
|
169
|
+
metric: vitalMetricValidator,
|
|
170
|
+
count: v.number(),
|
|
171
|
+
sum: v.number(),
|
|
172
|
+
histogram: v.array(v.number()),
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
})
|
|
176
|
+
.index("by_key", ["siteId", "bucketStart", "device", "page", "shard"])
|
|
177
|
+
.index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
|
|
178
|
+
|
|
153
179
|
affiliateDailyRollups: defineTable({
|
|
154
180
|
siteId: v.id("sites"),
|
|
155
181
|
affiliateId: v.id("affiliates"),
|
package/src/component/sites.ts
CHANGED
|
@@ -32,7 +32,8 @@ export const create = mutation({
|
|
|
32
32
|
handler: async (ctx, args) => {
|
|
33
33
|
const ownerId = cleanString(args.ownerId, 128);
|
|
34
34
|
const name = cleanString(args.name, 120);
|
|
35
|
-
if (!ownerId || !name)
|
|
35
|
+
if (!ownerId || !name)
|
|
36
|
+
fail("INVALID_SITE", "ownerId and name are required");
|
|
36
37
|
|
|
37
38
|
let domains: string[];
|
|
38
39
|
let timezone: string | undefined;
|
|
@@ -46,7 +47,10 @@ export const create = mutation({
|
|
|
46
47
|
: undefined;
|
|
47
48
|
currency = sanitizeCurrency(args.currency ?? "USD");
|
|
48
49
|
} catch (error) {
|
|
49
|
-
fail(
|
|
50
|
+
fail(
|
|
51
|
+
"INVALID_SITE",
|
|
52
|
+
error instanceof Error ? error.message : "invalid site",
|
|
53
|
+
);
|
|
50
54
|
}
|
|
51
55
|
const cookieless = args.cookieless ?? true;
|
|
52
56
|
|
|
@@ -65,7 +69,10 @@ export const create = mutation({
|
|
|
65
69
|
(existing.currency ?? "USD") !== currency ||
|
|
66
70
|
existing.cookieless !== cookieless
|
|
67
71
|
) {
|
|
68
|
-
fail(
|
|
72
|
+
fail(
|
|
73
|
+
"CONFLICT",
|
|
74
|
+
"site name is already associated with different settings",
|
|
75
|
+
);
|
|
69
76
|
}
|
|
70
77
|
await ensureAnalyticsControl(ctx, existing._id, Date.now());
|
|
71
78
|
return existing._id;
|
|
@@ -137,22 +144,26 @@ export const update = mutation({
|
|
|
137
144
|
patch.name = name;
|
|
138
145
|
}
|
|
139
146
|
try {
|
|
140
|
-
if (args.domains !== undefined)
|
|
147
|
+
if (args.domains !== undefined)
|
|
148
|
+
patch.domains = normalizeDomains(args.domains);
|
|
141
149
|
if (args.timezone !== undefined) {
|
|
142
|
-
patch.timezone =
|
|
143
|
-
? undefined
|
|
144
|
-
: validateTimezone(args.timezone);
|
|
150
|
+
patch.timezone =
|
|
151
|
+
args.timezone === null ? undefined : validateTimezone(args.timezone);
|
|
145
152
|
}
|
|
146
153
|
if (args.networkId !== undefined) {
|
|
147
|
-
patch.networkId =
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
patch.networkId =
|
|
155
|
+
args.networkId === null
|
|
156
|
+
? undefined
|
|
157
|
+
: sanitizeOpaqueId(args.networkId, "networkId");
|
|
150
158
|
}
|
|
151
159
|
if (args.currency !== undefined) {
|
|
152
160
|
patch.currency = sanitizeCurrency(args.currency);
|
|
153
161
|
}
|
|
154
162
|
} catch (error) {
|
|
155
|
-
fail(
|
|
163
|
+
fail(
|
|
164
|
+
"INVALID_SITE",
|
|
165
|
+
error instanceof Error ? error.message : "invalid site",
|
|
166
|
+
);
|
|
156
167
|
}
|
|
157
168
|
if (
|
|
158
169
|
patch.currency !== undefined &&
|
|
@@ -7,6 +7,15 @@ export const eventTypeValidator = v.union(
|
|
|
7
7
|
v.literal("conversion"),
|
|
8
8
|
v.literal("heartbeat"),
|
|
9
9
|
v.literal("outbound"),
|
|
10
|
+
v.literal("vital"),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export const vitalMetricValidator = v.union(
|
|
14
|
+
v.literal("LCP"),
|
|
15
|
+
v.literal("CLS"),
|
|
16
|
+
v.literal("INP"),
|
|
17
|
+
v.literal("FCP"),
|
|
18
|
+
v.literal("TTFB"),
|
|
10
19
|
);
|
|
11
20
|
|
|
12
21
|
export const propertyValueValidator = v.union(
|
|
@@ -37,6 +46,9 @@ export const trackerEventValidator = v.object({
|
|
|
37
46
|
revenueCents: v.optional(v.number()),
|
|
38
47
|
currency: v.optional(v.string()),
|
|
39
48
|
affiliateSlug: v.optional(v.string()),
|
|
49
|
+
// Web Vitals measurement: milliseconds, or CLS scaled by 1000. Only "vital"
|
|
50
|
+
// events carry it; sanitization strips it from every other type.
|
|
51
|
+
value: v.optional(v.number()),
|
|
40
52
|
});
|
|
41
53
|
|
|
42
54
|
export const ingestContextValidator = v.object({
|
|
@@ -50,12 +62,10 @@ export const ingestContextValidator = v.object({
|
|
|
50
62
|
visitorKey: v.optional(v.string()),
|
|
51
63
|
});
|
|
52
64
|
|
|
53
|
-
export const batchedEventValidator = trackerEventValidator.extend(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
},
|
|
58
|
-
);
|
|
65
|
+
export const batchedEventValidator = trackerEventValidator.extend({
|
|
66
|
+
...ingestContextValidator.fields,
|
|
67
|
+
aggregateCountry: v.optional(v.string()),
|
|
68
|
+
});
|
|
59
69
|
|
|
60
70
|
export const siteFieldsValidator = v.object({
|
|
61
71
|
ownerId: v.string(),
|
|
@@ -236,7 +246,6 @@ export const trustedConversionValidator = v.object({
|
|
|
236
246
|
properties: v.optional(eventPropertiesValidator),
|
|
237
247
|
});
|
|
238
248
|
|
|
239
|
-
|
|
240
249
|
export type TrackerEvent = Infer<typeof trackerEventValidator>;
|
|
241
250
|
export type IngestContext = Infer<typeof ingestContextValidator>;
|
|
242
251
|
export type EventProperties = Infer<typeof eventPropertiesValidator>;
|
|
@@ -59,14 +59,21 @@ export const link = mutation({
|
|
|
59
59
|
const existing = await findAlias(ctx, args.siteId, previousVisitorId);
|
|
60
60
|
if (existing) {
|
|
61
61
|
if (existing.visitorId !== visitorId) {
|
|
62
|
-
fail(
|
|
62
|
+
fail(
|
|
63
|
+
"CONFLICT",
|
|
64
|
+
"previousVisitorId is already linked to a different visitor",
|
|
65
|
+
);
|
|
63
66
|
}
|
|
64
67
|
return { linked: false, aliasCount: aliases.length };
|
|
65
68
|
}
|
|
66
69
|
if (aliases.length >= MAX_VISITOR_ALIASES) {
|
|
67
|
-
fail(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
fail(
|
|
71
|
+
"LIMIT_EXCEEDED",
|
|
72
|
+
`a visitor can hold at most ${MAX_VISITOR_ALIASES} aliases`,
|
|
73
|
+
{
|
|
74
|
+
limit: MAX_VISITOR_ALIASES,
|
|
75
|
+
},
|
|
76
|
+
);
|
|
70
77
|
}
|
|
71
78
|
await ctx.db.insert("visitorAliases", {
|
|
72
79
|
siteId: args.siteId,
|
|
@@ -95,7 +102,11 @@ export async function resolveVisitorIdentities(
|
|
|
95
102
|
return [identity, ...aliases.map((row) => row.previousVisitorId)];
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
function findAlias(
|
|
105
|
+
function findAlias(
|
|
106
|
+
ctx: QueryCtx,
|
|
107
|
+
siteId: Id<"sites">,
|
|
108
|
+
previousVisitorId: string,
|
|
109
|
+
) {
|
|
99
110
|
return ctx.db
|
|
100
111
|
.query("visitorAliases")
|
|
101
112
|
.withIndex("by_siteId_and_previousVisitorId", (range) =>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { VITAL_HISTOGRAM_EDGES, MAX_VITAL_VALUE } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Field-measured Web Vitals. Values are integers: milliseconds for the time
|
|
5
|
+
* metrics, and CLS scaled by 1000 so one shared histogram covers every metric.
|
|
6
|
+
*/
|
|
7
|
+
export const VITAL_METRICS = ["LCP", "CLS", "INP", "FCP", "TTFB"] as const;
|
|
8
|
+
|
|
9
|
+
export type VitalMetric = (typeof VITAL_METRICS)[number];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The Google-published rating thresholds. Every threshold is also a histogram
|
|
13
|
+
* edge, so good/needs-improvement/poor counts are exact, never interpolated.
|
|
14
|
+
*/
|
|
15
|
+
export const VITAL_THRESHOLDS: Record<
|
|
16
|
+
VitalMetric,
|
|
17
|
+
{ good: number; poor: number }
|
|
18
|
+
> = {
|
|
19
|
+
LCP: { good: 2_500, poor: 4_000 },
|
|
20
|
+
CLS: { good: 100, poor: 250 },
|
|
21
|
+
INP: { good: 200, poor: 500 },
|
|
22
|
+
FCP: { good: 1_800, poor: 3_000 },
|
|
23
|
+
TTFB: { good: 800, poor: 1_800 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Sentinel row keys; sanitized paths always start with "/" so neither collides. */
|
|
27
|
+
export const VITAL_ALL = "(all)";
|
|
28
|
+
export const VITAL_OTHER_PAGES = "(other)";
|
|
29
|
+
|
|
30
|
+
/** Bounded device classes; anything else folds into "unknown". */
|
|
31
|
+
export const VITAL_DEVICES = ["desktop", "mobile", "tablet"] as const;
|
|
32
|
+
|
|
33
|
+
export function vitalDevice(device: string | undefined): string {
|
|
34
|
+
return device && (VITAL_DEVICES as readonly string[]).includes(device)
|
|
35
|
+
? device
|
|
36
|
+
: "unknown";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const VITAL_HISTOGRAM_BUCKETS = VITAL_HISTOGRAM_EDGES.length + 1;
|
|
40
|
+
|
|
41
|
+
export function isVitalMetric(value: string): value is VitalMetric {
|
|
42
|
+
return (VITAL_METRICS as readonly string[]).includes(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isValidVitalValue(value: number): boolean {
|
|
46
|
+
return Number.isFinite(value) && value >= 0 && value <= MAX_VITAL_VALUE;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function emptyVitalHistogram(): number[] {
|
|
50
|
+
return Array.from({ length: VITAL_HISTOGRAM_BUCKETS }, () => 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Bucket i holds values in (edge[i-1], edge[i]]; the last bucket is open. */
|
|
54
|
+
export function vitalHistogramBucket(value: number): number {
|
|
55
|
+
for (let index = 0; index < VITAL_HISTOGRAM_EDGES.length; index += 1) {
|
|
56
|
+
if (value <= VITAL_HISTOGRAM_EDGES[index]) return index;
|
|
57
|
+
}
|
|
58
|
+
return VITAL_HISTOGRAM_EDGES.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function addToVitalHistogram(
|
|
62
|
+
histogram: number[],
|
|
63
|
+
value: number,
|
|
64
|
+
): number[] {
|
|
65
|
+
const next = normalizedVitalHistogram(histogram);
|
|
66
|
+
next[vitalHistogramBucket(value)] += 1;
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function mergeVitalHistograms(
|
|
71
|
+
left: number[],
|
|
72
|
+
right: number[],
|
|
73
|
+
): number[] {
|
|
74
|
+
const result = normalizedVitalHistogram(left);
|
|
75
|
+
const addition = normalizedVitalHistogram(right);
|
|
76
|
+
for (let index = 0; index < result.length; index += 1) {
|
|
77
|
+
result[index] += addition[index];
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Percentile estimated by linear interpolation inside the winning bucket. The
|
|
84
|
+
* top bucket is open-ended, so estimates saturate at the highest edge; a
|
|
85
|
+
* reported p75 equal to that edge means "at least this much".
|
|
86
|
+
*/
|
|
87
|
+
export function vitalHistogramPercentile(
|
|
88
|
+
histogram: number[],
|
|
89
|
+
fraction: number,
|
|
90
|
+
): number {
|
|
91
|
+
const buckets = normalizedVitalHistogram(histogram);
|
|
92
|
+
const total = buckets.reduce((sum, count) => sum + count, 0);
|
|
93
|
+
if (total === 0) return 0;
|
|
94
|
+
const target = Math.ceil(total * fraction);
|
|
95
|
+
let cumulative = 0;
|
|
96
|
+
for (let index = 0; index < buckets.length; index += 1) {
|
|
97
|
+
const count = buckets[index];
|
|
98
|
+
if (count === 0) continue;
|
|
99
|
+
if (cumulative + count >= target) {
|
|
100
|
+
const lower = index === 0 ? 0 : VITAL_HISTOGRAM_EDGES[index - 1];
|
|
101
|
+
const upper =
|
|
102
|
+
index < VITAL_HISTOGRAM_EDGES.length
|
|
103
|
+
? VITAL_HISTOGRAM_EDGES[index]
|
|
104
|
+
: VITAL_HISTOGRAM_EDGES[VITAL_HISTOGRAM_EDGES.length - 1];
|
|
105
|
+
const position = (target - cumulative) / count;
|
|
106
|
+
return Math.round(Math.min(upper, lower + position * (upper - lower)));
|
|
107
|
+
}
|
|
108
|
+
cumulative += count;
|
|
109
|
+
}
|
|
110
|
+
return VITAL_HISTOGRAM_EDGES[VITAL_HISTOGRAM_EDGES.length - 1];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Exact rating counts; every threshold is a bucket edge by construction. */
|
|
114
|
+
export function vitalRatingCounts(
|
|
115
|
+
histogram: number[],
|
|
116
|
+
metric: VitalMetric,
|
|
117
|
+
): { good: number; needsImprovement: number; poor: number } {
|
|
118
|
+
const buckets = normalizedVitalHistogram(histogram);
|
|
119
|
+
const thresholds = VITAL_THRESHOLDS[metric];
|
|
120
|
+
let good = 0;
|
|
121
|
+
let needsImprovement = 0;
|
|
122
|
+
let poor = 0;
|
|
123
|
+
for (let index = 0; index < buckets.length; index += 1) {
|
|
124
|
+
const upper =
|
|
125
|
+
index < VITAL_HISTOGRAM_EDGES.length
|
|
126
|
+
? VITAL_HISTOGRAM_EDGES[index]
|
|
127
|
+
: Number.POSITIVE_INFINITY;
|
|
128
|
+
if (upper <= thresholds.good) good += buckets[index];
|
|
129
|
+
else if (upper <= thresholds.poor) needsImprovement += buckets[index];
|
|
130
|
+
else poor += buckets[index];
|
|
131
|
+
}
|
|
132
|
+
return { good, needsImprovement, poor };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizedVitalHistogram(histogram: number[]): number[] {
|
|
136
|
+
const result = Array.from({ length: VITAL_HISTOGRAM_BUCKETS }, () => 0);
|
|
137
|
+
for (
|
|
138
|
+
let index = 0;
|
|
139
|
+
index < Math.min(histogram.length, result.length);
|
|
140
|
+
index += 1
|
|
141
|
+
) {
|
|
142
|
+
const count = histogram[index];
|
|
143
|
+
result[index] = Number.isSafeInteger(count) && count > 0 ? count : 0;
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|