@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.
- package/README.md +203 -56
- package/agent/integration.md +17 -15
- package/agent/manifest.json +6 -4
- 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 +486 -31
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +209 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +8 -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 +82 -8
- 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 +6 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +12 -0
- 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 +3 -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 +71 -37
- 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 +168 -29
- 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 +70 -4
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +232 -59
- 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.map +1 -1
- package/dist/component/sanitize.js +42 -11
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +83 -10
- package/dist/component/schema.js +34 -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/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 +29 -18
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +8 -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.map +1 -1
- package/dist/tracker/tracker.js +23 -11
- 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 +137 -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/federation.md +1 -1
- package/package.json +27 -9
- package/scripts/benchmark-ingest.mjs +101 -48
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +87 -5
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +8 -7
- package/src/component/constants.ts +12 -0
- 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 +126 -78
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +349 -130
- package/src/component/live.ts +5 -4
- package/src/component/reports.ts +572 -221
- package/src/component/retention.ts +221 -98
- package/src/component/sanitize.ts +77 -27
- package/src/component/schema.ts +46 -5
- package/src/component/sites.ts +22 -11
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +19 -7
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
IngestContext,
|
|
9
9
|
TrackerEvent,
|
|
10
10
|
} from "./validators.js";
|
|
11
|
+
import { isValidVitalValue, isVitalMetric } from "./vitals.js";
|
|
11
12
|
|
|
12
13
|
const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
13
14
|
const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
|
|
@@ -62,9 +63,10 @@ export function sanitizePath(value: string): string {
|
|
|
62
63
|
const input = requireString(value, "path", 2048);
|
|
63
64
|
let path: string;
|
|
64
65
|
try {
|
|
65
|
-
path =
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
path =
|
|
67
|
+
input.startsWith("http://") || input.startsWith("https://")
|
|
68
|
+
? new URL(input).pathname
|
|
69
|
+
: input.split(/[?#]/, 1)[0];
|
|
68
70
|
} catch {
|
|
69
71
|
throw new Error("path is invalid");
|
|
70
72
|
}
|
|
@@ -112,14 +114,19 @@ export function sanitizeProperties(
|
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
|
|
115
|
-
if (!Number.isFinite(event.timestamp))
|
|
117
|
+
if (!Number.isFinite(event.timestamp))
|
|
118
|
+
throw new Error("timestamp is invalid");
|
|
116
119
|
if (
|
|
117
120
|
event.timestamp < now - MAX_EVENT_AGE_MS ||
|
|
118
121
|
event.timestamp > now + MAX_FUTURE_SKEW_MS
|
|
119
122
|
) {
|
|
120
123
|
throw new Error("timestamp is outside the accepted window");
|
|
121
124
|
}
|
|
122
|
-
if (
|
|
125
|
+
if (
|
|
126
|
+
!Number.isSafeInteger(event.sequence) ||
|
|
127
|
+
event.sequence < 0 ||
|
|
128
|
+
event.sequence > 1_000_000_000
|
|
129
|
+
) {
|
|
123
130
|
throw new Error("sequence is out of range");
|
|
124
131
|
}
|
|
125
132
|
if (event.revenueCents !== undefined && !isValidMoney(event.revenueCents)) {
|
|
@@ -130,9 +137,20 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
|
|
|
130
137
|
throw new Error("name is required for custom events");
|
|
131
138
|
}
|
|
132
139
|
if (event.type === "conversion" && !name) name = "conversion";
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
if (event.type === "vital") {
|
|
141
|
+
if (!name || !isVitalMetric(name)) {
|
|
142
|
+
throw new Error("vital name must be a Web Vitals metric");
|
|
143
|
+
}
|
|
144
|
+
if (event.value === undefined || !isValidVitalValue(event.value)) {
|
|
145
|
+
throw new Error("vital value is out of range");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const currency =
|
|
149
|
+
event.currency === undefined
|
|
150
|
+
? event.type === "conversion"
|
|
151
|
+
? "USD"
|
|
152
|
+
: undefined
|
|
153
|
+
: sanitizeCurrency(event.currency);
|
|
136
154
|
return {
|
|
137
155
|
eventId: sanitizeOpaqueId(event.eventId, "eventId"),
|
|
138
156
|
sessionId: sanitizeOpaqueId(event.sessionId, "sessionId"),
|
|
@@ -140,19 +158,26 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
|
|
|
140
158
|
type: event.type,
|
|
141
159
|
name,
|
|
142
160
|
path: sanitizePath(event.path),
|
|
143
|
-
referrer:
|
|
144
|
-
|
|
145
|
-
|
|
161
|
+
referrer:
|
|
162
|
+
event.referrer === undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: sanitizePublicUrl(event.referrer),
|
|
146
165
|
timestamp: Math.trunc(event.timestamp),
|
|
147
166
|
sequence: event.sequence,
|
|
148
|
-
target:
|
|
149
|
-
|
|
150
|
-
|
|
167
|
+
target:
|
|
168
|
+
event.target === undefined
|
|
169
|
+
? undefined
|
|
170
|
+
: cleanString(event.target.replace(/\s+/g, " "), 160) || undefined,
|
|
151
171
|
href: event.href === undefined ? undefined : sanitizePublicUrl(event.href),
|
|
152
172
|
properties: sanitizeProperties(event.properties),
|
|
153
173
|
revenueCents: event.revenueCents,
|
|
154
174
|
currency,
|
|
155
175
|
affiliateSlug: optionalAffiliateSlug(event.affiliateSlug),
|
|
176
|
+
// Only measurements keep a value; a stray number on any other type is noise.
|
|
177
|
+
value:
|
|
178
|
+
event.type === "vital" && event.value !== undefined
|
|
179
|
+
? Math.round(event.value)
|
|
180
|
+
: undefined,
|
|
156
181
|
};
|
|
157
182
|
}
|
|
158
183
|
|
|
@@ -167,7 +192,9 @@ function optionalAffiliateSlug(value: string | undefined): string | undefined {
|
|
|
167
192
|
|
|
168
193
|
export function normalizeDomains(domains: string[]): string[] {
|
|
169
194
|
if (domains.length === 0 || domains.length > MAX_SITE_DOMAINS) {
|
|
170
|
-
throw new Error(
|
|
195
|
+
throw new Error(
|
|
196
|
+
`domains must contain between 1 and ${MAX_SITE_DOMAINS} entries`,
|
|
197
|
+
);
|
|
171
198
|
}
|
|
172
199
|
const normalized = domains.map(normalizeDomainPattern);
|
|
173
200
|
return [...new Set(normalized)].sort();
|
|
@@ -185,7 +212,11 @@ export function normalizeDomainPattern(value: string): string {
|
|
|
185
212
|
throw new Error("domains cannot include paths or query strings");
|
|
186
213
|
}
|
|
187
214
|
hostname = url.hostname;
|
|
188
|
-
} else if (
|
|
215
|
+
} else if (
|
|
216
|
+
hostInput.includes("/") ||
|
|
217
|
+
hostInput.includes("?") ||
|
|
218
|
+
hostInput.includes("#")
|
|
219
|
+
) {
|
|
189
220
|
throw new Error("domain is invalid");
|
|
190
221
|
}
|
|
191
222
|
hostname = hostname.replace(/\.$/, "");
|
|
@@ -214,7 +245,9 @@ export function originAllowed(origin: string, domains: string[]): boolean {
|
|
|
214
245
|
});
|
|
215
246
|
}
|
|
216
247
|
|
|
217
|
-
export function validateTimezone(
|
|
248
|
+
export function validateTimezone(
|
|
249
|
+
value: string | undefined,
|
|
250
|
+
): string | undefined {
|
|
218
251
|
if (value === undefined || value.trim() === "") return undefined;
|
|
219
252
|
const timezone = cleanString(value, 80);
|
|
220
253
|
try {
|
|
@@ -225,17 +258,33 @@ export function validateTimezone(value: string | undefined): string | undefined
|
|
|
225
258
|
}
|
|
226
259
|
}
|
|
227
260
|
|
|
228
|
-
export function sanitizeContext(
|
|
261
|
+
export function sanitizeContext(
|
|
262
|
+
context: IngestContext | undefined,
|
|
263
|
+
): IngestContext {
|
|
229
264
|
const latitude = finiteCoordinate(context?.latitude, -90, 90);
|
|
230
265
|
const longitude = finiteCoordinate(context?.longitude, -180, 180);
|
|
231
266
|
return {
|
|
232
|
-
country: context?.country
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
267
|
+
country: context?.country
|
|
268
|
+
? cleanString(context.country.toUpperCase(), 2) || undefined
|
|
269
|
+
: undefined,
|
|
270
|
+
city: context?.city
|
|
271
|
+
? cleanString(context.city, 80) || undefined
|
|
272
|
+
: undefined,
|
|
273
|
+
latitude:
|
|
274
|
+
latitude === undefined ? undefined : Math.round(latitude * 10) / 10,
|
|
275
|
+
longitude:
|
|
276
|
+
longitude === undefined ? undefined : Math.round(longitude * 10) / 10,
|
|
277
|
+
browser: context?.browser
|
|
278
|
+
? cleanString(context.browser, 32) || undefined
|
|
279
|
+
: undefined,
|
|
237
280
|
os: context?.os ? cleanString(context.os, 32) || undefined : undefined,
|
|
238
|
-
device: context?.device
|
|
281
|
+
device: context?.device
|
|
282
|
+
? cleanString(context.device, 24) || undefined
|
|
283
|
+
: undefined,
|
|
284
|
+
visitorKey:
|
|
285
|
+
context?.visitorKey && SAFE_OPAQUE_ID.test(context.visitorKey)
|
|
286
|
+
? cleanString(context.visitorKey, 128) || undefined
|
|
287
|
+
: undefined,
|
|
239
288
|
};
|
|
240
289
|
}
|
|
241
290
|
|
|
@@ -254,8 +303,8 @@ export function measureBatchBytes(
|
|
|
254
303
|
events: TrackerEvent[],
|
|
255
304
|
): { batchBytes: number; eventByteCounts: number[] } {
|
|
256
305
|
const encoder = new TextEncoder();
|
|
257
|
-
const eventByteCounts = events.map(
|
|
258
|
-
encoder.encode(JSON.stringify(event)).byteLength
|
|
306
|
+
const eventByteCounts = events.map(
|
|
307
|
+
(event) => encoder.encode(JSON.stringify(event)).byteLength,
|
|
259
308
|
);
|
|
260
309
|
// The empty envelope already includes both array brackets; only commas remain.
|
|
261
310
|
const batchBytes =
|
|
@@ -291,6 +340,7 @@ function finiteCoordinate(
|
|
|
291
340
|
maximum: number,
|
|
292
341
|
): number | undefined {
|
|
293
342
|
if (value === undefined) return undefined;
|
|
294
|
-
if (!Number.isFinite(value) || value < minimum || value > maximum)
|
|
343
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum)
|
|
344
|
+
return undefined;
|
|
295
345
|
return value;
|
|
296
346
|
}
|
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"),
|
|
@@ -283,6 +309,21 @@ export default defineSchema({
|
|
|
283
309
|
.index("by_siteId", ["siteId"])
|
|
284
310
|
.index("by_siteId_and_slug", ["siteId", "slug"]),
|
|
285
311
|
|
|
312
|
+
visitorSecrets: defineTable({
|
|
313
|
+
key: v.literal("current"),
|
|
314
|
+
value: v.string(),
|
|
315
|
+
createdAt: v.number(),
|
|
316
|
+
}).index("by_key", ["key"]),
|
|
317
|
+
|
|
318
|
+
visitorAliases: defineTable({
|
|
319
|
+
siteId: v.id("sites"),
|
|
320
|
+
visitorId: v.string(),
|
|
321
|
+
previousVisitorId: v.string(),
|
|
322
|
+
linkedAt: v.number(),
|
|
323
|
+
})
|
|
324
|
+
.index("by_siteId_and_visitorId", ["siteId", "visitorId"])
|
|
325
|
+
.index("by_siteId_and_previousVisitorId", ["siteId", "previousVisitorId"]),
|
|
326
|
+
|
|
286
327
|
visitorAttributions: defineTable({
|
|
287
328
|
siteId: v.id("sites"),
|
|
288
329
|
visitorId: v.string(),
|
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 &&
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export type ClientClassification = {
|
|
2
|
+
browser: string;
|
|
3
|
+
os: string;
|
|
4
|
+
device: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// The tracker never reports the client: everything here is derived from headers
|
|
8
|
+
// the browser sets itself. Chromium sends the low-entropy `Sec-CH-UA` hints on
|
|
9
|
+
// cross-origin requests without an `Accept-CH` opt-in, which is the only way to
|
|
10
|
+
// tell Brave apart from Chrome — Brave deliberately ships Chrome's User-Agent.
|
|
11
|
+
// Firefox and Safari implement no client hints, so the User-Agent stays the
|
|
12
|
+
// fallback for them and for insecure origins, where no hint is sent at all.
|
|
13
|
+
const MAX_HINT_LENGTH = 512;
|
|
14
|
+
const MAX_HINT_BRANDS = 16;
|
|
15
|
+
|
|
16
|
+
const BRAND_PATTERN =
|
|
17
|
+
/"((?:[^"\\]|\\.)*)"(?:\s*;\s*v\s*=\s*"(?:[^"\\]|\\.)*")?/g;
|
|
18
|
+
const PLATFORM_PATTERN = /^\s*"((?:[^"\\]|\\.)*)"\s*$/;
|
|
19
|
+
|
|
20
|
+
// `Sec-CH-UA` is attacker-controlled on a public ingestion route, so brands are
|
|
21
|
+
// allowlisted: anything unrecognized (including the GREASE entries Chromium
|
|
22
|
+
// injects, such as `Not.A/Brand`) falls back to the User-Agent instead of
|
|
23
|
+
// entering the browser dimension verbatim.
|
|
24
|
+
const HINT_BRANDS = new Map<string, string>([
|
|
25
|
+
["brave", "Brave"],
|
|
26
|
+
["chromium", "Chromium"],
|
|
27
|
+
["google chrome", "Chrome"],
|
|
28
|
+
["microsoft edge", "Edge"],
|
|
29
|
+
["opera", "Opera"],
|
|
30
|
+
["opera gx", "Opera"],
|
|
31
|
+
["samsung internet", "Samsung Internet"],
|
|
32
|
+
["vivaldi", "Vivaldi"],
|
|
33
|
+
["yandex", "Yandex"],
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// Every Chromium brand list contains "Chromium", and Chrome adds "Google
|
|
37
|
+
// Chrome" beside it; both only win when no fork identifies itself.
|
|
38
|
+
const GENERIC_BRANDS = new Set(["Chrome", "Chromium"]);
|
|
39
|
+
|
|
40
|
+
const HINT_PLATFORMS = new Map<string, string>([
|
|
41
|
+
["android", "Android"],
|
|
42
|
+
["chrome os", "ChromeOS"],
|
|
43
|
+
["chromium os", "ChromeOS"],
|
|
44
|
+
["ios", "iOS"],
|
|
45
|
+
["linux", "Linux"],
|
|
46
|
+
["macos", "macOS"],
|
|
47
|
+
["windows", "Windows"],
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Ordered: every Chromium fork carries `Chrome/`, and every Chromium and Gecko
|
|
51
|
+
// browser on iOS carries `Safari/`, so the specific tokens must be tested
|
|
52
|
+
// first. Brave has no token at all and is only detectable through the hints.
|
|
53
|
+
const AGENT_BROWSERS: readonly (readonly [RegExp, string])[] = [
|
|
54
|
+
[/Edg(?:A|iOS)?\//, "Edge"],
|
|
55
|
+
[/(?:Firefox|FxiOS)\//, "Firefox"],
|
|
56
|
+
[/OPR\//, "Opera"],
|
|
57
|
+
[/SamsungBrowser\//, "Samsung Internet"],
|
|
58
|
+
[/Vivaldi\//, "Vivaldi"],
|
|
59
|
+
[/YaBrowser\//, "Yandex"],
|
|
60
|
+
[/(?:Chrome|CriOS)\//, "Chrome"],
|
|
61
|
+
[/Safari\//, "Safari"],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Ordered: Android and ChromeOS user agents also carry the `Linux` token.
|
|
65
|
+
const AGENT_PLATFORMS: readonly (readonly [RegExp, string])[] = [
|
|
66
|
+
[/Android/, "Android"],
|
|
67
|
+
[/iPhone|iPad|iPod/, "iOS"],
|
|
68
|
+
[/CrOS/, "ChromeOS"],
|
|
69
|
+
[/Windows/, "Windows"],
|
|
70
|
+
[/Mac OS X/, "macOS"],
|
|
71
|
+
[/Linux/, "Linux"],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// Ordered: tablet user agents that carry `Android` must not be read as mobile.
|
|
75
|
+
const AGENT_DEVICES: readonly (readonly [RegExp, string])[] = [
|
|
76
|
+
[/iPad|Tablet/, "tablet"],
|
|
77
|
+
[/Mobile|Android|iPhone/, "mobile"],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export function classifyClient(
|
|
81
|
+
userAgent: string,
|
|
82
|
+
headers: Headers,
|
|
83
|
+
): ClientClassification {
|
|
84
|
+
const agent = classifyUserAgent(userAgent);
|
|
85
|
+
const brand = brandFromClientHints(headers.get("sec-ch-ua"));
|
|
86
|
+
const platform = platformFromClientHints(headers.get("sec-ch-ua-platform"));
|
|
87
|
+
const mobile = mobileFromClientHints(headers.get("sec-ch-ua-mobile"));
|
|
88
|
+
return {
|
|
89
|
+
browser: brand ?? agent.browser,
|
|
90
|
+
os: platform ?? agent.os,
|
|
91
|
+
device: deviceFromClientHints(mobile, platform) ?? agent.device,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function classifyUserAgent(userAgent: string): ClientClassification {
|
|
96
|
+
return {
|
|
97
|
+
browser: matchAgent(AGENT_BROWSERS, userAgent) ?? "Other",
|
|
98
|
+
os: matchAgent(AGENT_PLATFORMS, userAgent) ?? "Other",
|
|
99
|
+
device: matchAgent(AGENT_DEVICES, userAgent) ?? "desktop",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function matchAgent(
|
|
104
|
+
table: readonly (readonly [RegExp, string])[],
|
|
105
|
+
userAgent: string,
|
|
106
|
+
): string | undefined {
|
|
107
|
+
for (const [pattern, value] of table) {
|
|
108
|
+
if (pattern.test(userAgent)) return value;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function brandFromClientHints(header: string | null): string | undefined {
|
|
114
|
+
if (!header || header.length > MAX_HINT_LENGTH) return undefined;
|
|
115
|
+
let generic: string | undefined;
|
|
116
|
+
let seen = 0;
|
|
117
|
+
for (const match of header.matchAll(BRAND_PATTERN)) {
|
|
118
|
+
if (seen >= MAX_HINT_BRANDS) break;
|
|
119
|
+
seen += 1;
|
|
120
|
+
const brand = HINT_BRANDS.get(unquote(match[1]).trim().toLowerCase());
|
|
121
|
+
if (!brand) continue;
|
|
122
|
+
if (!GENERIC_BRANDS.has(brand)) return brand;
|
|
123
|
+
if (brand === "Chrome" || generic === undefined) generic = brand;
|
|
124
|
+
}
|
|
125
|
+
return generic;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function platformFromClientHints(header: string | null): string | undefined {
|
|
129
|
+
if (!header || header.length > MAX_HINT_LENGTH) return undefined;
|
|
130
|
+
const match = PLATFORM_PATTERN.exec(header);
|
|
131
|
+
if (!match) return undefined;
|
|
132
|
+
return HINT_PLATFORMS.get(unquote(match[1]).trim().toLowerCase());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mobileFromClientHints(header: string | null): boolean | undefined {
|
|
136
|
+
if (header === null) return undefined;
|
|
137
|
+
const value = header.trim();
|
|
138
|
+
if (value === "?1") return true;
|
|
139
|
+
if (value === "?0") return false;
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function deviceFromClientHints(
|
|
144
|
+
mobile: boolean | undefined,
|
|
145
|
+
platform: string | undefined,
|
|
146
|
+
): string | undefined {
|
|
147
|
+
if (mobile === undefined) return undefined;
|
|
148
|
+
if (mobile) return "mobile";
|
|
149
|
+
// Chrome on Android tablets reports `Sec-CH-UA-Mobile: ?0`. The explicit
|
|
150
|
+
// `Sec-CH-UA-Form-Factors` hint is high entropy, so it would need an
|
|
151
|
+
// `Accept-CH` opt-in and a permissions-policy delegation from every tracked
|
|
152
|
+
// site; "Android but not mobile" is the only tablet signal available here.
|
|
153
|
+
if (platform === "Android") return "tablet";
|
|
154
|
+
return "desktop";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function unquote(value: string): string {
|
|
158
|
+
return value.replace(/\\(.)/g, "$1");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Self-declared crawlers, link unfurlers, uptime monitors, HTTP libraries, and
|
|
162
|
+
// headless browsers. A URL inside the User-Agent is the crawler convention
|
|
163
|
+
// ("+https://…/bot.html"); no browser ships one. An empty User-Agent is left
|
|
164
|
+
// alone because some runtimes do not expose it at all, and a scripted browser
|
|
165
|
+
// that spoofs a stock User-Agent is indistinguishable from a person here.
|
|
166
|
+
const BOT_PATTERN =
|
|
167
|
+
/bot|crawl|spider|slurp|headless|phantomjs|lighthouse|pingdom|gtmetrix|uptime|monitor|scrap|fetch|curl\/|wget\/|python|java\/|go-http-client|okhttp|axios|libwww|httpclient|facebookexternalhit|whatsapp|embedly|quora link preview|preview|mediapartners|feedfetcher|validator|https?:\/\//i;
|
|
168
|
+
|
|
169
|
+
export function isKnownBot(userAgent: string): boolean {
|
|
170
|
+
return BOT_PATTERN.test(userAgent);
|
|
171
|
+
}
|
|
@@ -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({
|
|
@@ -47,14 +59,13 @@ export const ingestContextValidator = v.object({
|
|
|
47
59
|
browser: v.optional(v.string()),
|
|
48
60
|
os: v.optional(v.string()),
|
|
49
61
|
device: v.optional(v.string()),
|
|
62
|
+
visitorKey: v.optional(v.string()),
|
|
50
63
|
});
|
|
51
64
|
|
|
52
|
-
export const batchedEventValidator = trackerEventValidator.extend(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
},
|
|
57
|
-
);
|
|
65
|
+
export const batchedEventValidator = trackerEventValidator.extend({
|
|
66
|
+
...ingestContextValidator.fields,
|
|
67
|
+
aggregateCountry: v.optional(v.string()),
|
|
68
|
+
});
|
|
58
69
|
|
|
59
70
|
export const siteFieldsValidator = v.object({
|
|
60
71
|
ownerId: v.string(),
|
|
@@ -72,6 +83,7 @@ export const sessionFieldsValidator = v.object({
|
|
|
72
83
|
siteId: v.id("sites"),
|
|
73
84
|
sessionId: v.string(),
|
|
74
85
|
visitorId: v.string(),
|
|
86
|
+
visitorKey: v.optional(v.string()),
|
|
75
87
|
startedAt: v.number(),
|
|
76
88
|
lastSeenAt: v.number(),
|
|
77
89
|
entryPath: v.string(),
|
|
@@ -128,6 +140,7 @@ export const eventFieldsValidator = v.object({
|
|
|
128
140
|
|
|
129
141
|
export const dimensionTypeValidator = v.union(
|
|
130
142
|
v.literal("source"),
|
|
143
|
+
v.literal("campaign"),
|
|
131
144
|
v.literal("page"),
|
|
132
145
|
v.literal("country"),
|
|
133
146
|
v.literal("device"),
|
|
@@ -233,7 +246,6 @@ export const trustedConversionValidator = v.object({
|
|
|
233
246
|
properties: v.optional(eventPropertiesValidator),
|
|
234
247
|
});
|
|
235
248
|
|
|
236
|
-
|
|
237
249
|
export type TrackerEvent = Infer<typeof trackerEventValidator>;
|
|
238
250
|
export type IngestContext = Infer<typeof ingestContextValidator>;
|
|
239
251
|
export type EventProperties = Infer<typeof eventPropertiesValidator>;
|