@iann29/rastro 0.1.0-alpha.4 → 0.1.0-alpha.5
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 +128 -29
- package/agent/manifest.json +1 -1
- package/dist/client/index.d.ts +69 -11
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +19 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +6 -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 +19 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/constants.d.ts +1 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +1 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/eventStore.d.ts +1 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +40 -29
- 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 +1 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +25 -6
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/reports.d.ts +10 -1
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +22 -15
- package/dist/component/reports.js.map +1 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +4 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +37 -6
- package/dist/component/schema.js +13 -0
- package/dist/component/schema.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 +16 -10
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +3 -1
- 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/tracker/generated.d.ts +4 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +4 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +23 -11
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/federation.md +1 -1
- package/package.json +12 -8
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +18 -1
- package/src/component/constants.ts +1 -0
- package/src/component/http.ts +55 -34
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +29 -3
- package/src/component/reports.ts +21 -14
- package/src/component/sanitize.ts +4 -1
- package/src/component/schema.ts +15 -0
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +3 -0
- package/src/component/visitors.ts +105 -0
package/src/component/ingest.ts
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
|
|
70
70
|
type DimensionType =
|
|
71
71
|
| "source"
|
|
72
|
+
| "campaign"
|
|
72
73
|
| "page"
|
|
73
74
|
| "country"
|
|
74
75
|
| "device"
|
|
@@ -146,6 +147,8 @@ type SessionState = {
|
|
|
146
147
|
isNewVisitorDay: boolean;
|
|
147
148
|
startedAt: number;
|
|
148
149
|
source: string;
|
|
150
|
+
visitorKey: string;
|
|
151
|
+
utmCampaign?: string;
|
|
149
152
|
country?: string;
|
|
150
153
|
city?: string;
|
|
151
154
|
latitude?: number;
|
|
@@ -783,8 +786,12 @@ async function accountEvent(
|
|
|
783
786
|
);
|
|
784
787
|
}
|
|
785
788
|
|
|
789
|
+
const campaignDimensions: Dimension[] = session.utmCampaign
|
|
790
|
+
? [{ type: "campaign", value: session.utmCampaign }]
|
|
791
|
+
: [];
|
|
786
792
|
const dimensions: Dimension[] = [
|
|
787
793
|
{ type: "source", value: session.source },
|
|
794
|
+
...campaignDimensions,
|
|
788
795
|
{ type: "page", value: event.path },
|
|
789
796
|
{ type: "country", value: session.country ?? "unknown" },
|
|
790
797
|
{ type: "device", value: input.context.device ?? "unknown" },
|
|
@@ -842,6 +849,8 @@ async function accountTrustedSession(
|
|
|
842
849
|
isNewVisitorDay: false,
|
|
843
850
|
startedAt: existing.startedAt,
|
|
844
851
|
source: existing.source,
|
|
852
|
+
utmCampaign: existing.utmCampaign,
|
|
853
|
+
visitorKey: existing.visitorKey ?? existing.visitorId,
|
|
845
854
|
country: existing.country,
|
|
846
855
|
city: existing.city,
|
|
847
856
|
latitude: existing.latitude,
|
|
@@ -865,8 +874,16 @@ async function upsertSession(
|
|
|
865
874
|
reportRollupDeltas: ReportRollupDeltas,
|
|
866
875
|
batchState?: BatchState,
|
|
867
876
|
): Promise<SessionState> {
|
|
868
|
-
|
|
877
|
+
// A host-supplied id is the identity; anonymous sessions count through the
|
|
878
|
+
// daily key derived by the HTTP action, or once per session without one.
|
|
879
|
+
const visitorKey =
|
|
880
|
+
existing?.visitorKey ??
|
|
881
|
+
(event.visitorId !== event.sessionId
|
|
882
|
+
? event.visitorId
|
|
883
|
+
: (context.visitorKey ?? event.sessionId));
|
|
869
884
|
const utmSource = stringProperty(event.properties, "utm_source");
|
|
885
|
+
// Campaign traffic names its own source; the referrer host is the fallback.
|
|
886
|
+
const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
|
|
870
887
|
const utmMedium = stringProperty(event.properties, "utm_medium");
|
|
871
888
|
const utmCampaign = stringProperty(event.properties, "utm_campaign");
|
|
872
889
|
const attribution = await attributionForEvent(
|
|
@@ -883,6 +900,7 @@ async function upsertSession(
|
|
|
883
900
|
siteId,
|
|
884
901
|
event,
|
|
885
902
|
attribution,
|
|
903
|
+
visitorKey,
|
|
886
904
|
);
|
|
887
905
|
}
|
|
888
906
|
const startedAt = Math.min(existing.startedAt, event.timestamp);
|
|
@@ -932,6 +950,8 @@ async function upsertSession(
|
|
|
932
950
|
Math.floor(event.timestamp / DAY_MS),
|
|
933
951
|
startedAt,
|
|
934
952
|
source: existing.source,
|
|
953
|
+
utmCampaign: existing.utmCampaign,
|
|
954
|
+
visitorKey,
|
|
935
955
|
country: context.country ?? existing.country,
|
|
936
956
|
city: context.city ?? existing.city,
|
|
937
957
|
latitude: context.latitude ?? existing.latitude,
|
|
@@ -967,6 +987,7 @@ async function upsertSession(
|
|
|
967
987
|
siteId,
|
|
968
988
|
sessionId: event.sessionId,
|
|
969
989
|
visitorId: event.visitorId,
|
|
990
|
+
visitorKey,
|
|
970
991
|
startedAt: event.timestamp,
|
|
971
992
|
lastSeenAt: event.timestamp,
|
|
972
993
|
entryPath: event.path,
|
|
@@ -1000,6 +1021,7 @@ async function upsertSession(
|
|
|
1000
1021
|
siteId,
|
|
1001
1022
|
event,
|
|
1002
1023
|
attribution,
|
|
1024
|
+
visitorKey,
|
|
1003
1025
|
);
|
|
1004
1026
|
}
|
|
1005
1027
|
if (batchState) {
|
|
@@ -1022,6 +1044,8 @@ async function upsertSession(
|
|
|
1022
1044
|
Math.floor(event.timestamp / DAY_MS),
|
|
1023
1045
|
startedAt: event.timestamp,
|
|
1024
1046
|
source,
|
|
1047
|
+
utmCampaign,
|
|
1048
|
+
visitorKey,
|
|
1025
1049
|
country: context.country,
|
|
1026
1050
|
city: context.city,
|
|
1027
1051
|
latitude: context.latitude,
|
|
@@ -1218,6 +1242,7 @@ async function accountHeartbeat(
|
|
|
1218
1242
|
isNewVisitorDay: false,
|
|
1219
1243
|
startedAt,
|
|
1220
1244
|
source: session.source,
|
|
1245
|
+
visitorKey: session.visitorKey ?? session.visitorId,
|
|
1221
1246
|
country: context.country ?? session.country,
|
|
1222
1247
|
city: context.city ?? session.city,
|
|
1223
1248
|
latitude: context.latitude ?? session.latitude,
|
|
@@ -1855,6 +1880,7 @@ function foldAffiliateVisitRollup(
|
|
|
1855
1880
|
siteId: Id<"sites">,
|
|
1856
1881
|
event: TrackerEvent,
|
|
1857
1882
|
attribution: AttributionSnapshot,
|
|
1883
|
+
visitorKey: string,
|
|
1858
1884
|
) {
|
|
1859
1885
|
const coordinates = reportRollupCoordinates(
|
|
1860
1886
|
siteId,
|
|
@@ -1875,7 +1901,7 @@ function foldAffiliateVisitRollup(
|
|
|
1875
1901
|
commissionCents: 0,
|
|
1876
1902
|
};
|
|
1877
1903
|
current.visits += 1;
|
|
1878
|
-
current.visitorSketch = addVisitor(current.visitorSketch,
|
|
1904
|
+
current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
|
|
1879
1905
|
deltas.affiliates.set(coordinates.key, current);
|
|
1880
1906
|
}
|
|
1881
1907
|
|
|
@@ -2076,7 +2102,7 @@ function foldAggregates(
|
|
|
2076
2102
|
: Number(session.isNewVisitorDay)
|
|
2077
2103
|
: 0;
|
|
2078
2104
|
if (recordTelemetry) {
|
|
2079
|
-
delta.visitorSketch = addVisitor(delta.visitorSketch,
|
|
2105
|
+
delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
|
|
2080
2106
|
}
|
|
2081
2107
|
delta.conversions += Number(financialConversion);
|
|
2082
2108
|
delta.revenueCents += revenueCents;
|
package/src/component/reports.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
goalFieldsValidator,
|
|
50
50
|
sessionFieldsValidator,
|
|
51
51
|
} from "./validators.js";
|
|
52
|
+
import { resolveVisitorIdentities } from "./visitors.js";
|
|
52
53
|
|
|
53
54
|
const sessionDocumentValidator = sessionFieldsValidator.extend({
|
|
54
55
|
_id: v.id("sessions"),
|
|
@@ -155,6 +156,7 @@ export const overview = query({
|
|
|
155
156
|
totals: metricTotalsValidator,
|
|
156
157
|
timeSeries: v.array(timePointValidator),
|
|
157
158
|
topSources: v.array(topItemValidator),
|
|
159
|
+
topCampaigns: v.array(topItemValidator),
|
|
158
160
|
topPages: v.array(topItemValidator),
|
|
159
161
|
topCountries: v.array(topItemValidator),
|
|
160
162
|
topDevices: v.array(topItemValidator),
|
|
@@ -174,6 +176,7 @@ export const overview = query({
|
|
|
174
176
|
}),
|
|
175
177
|
breakdowns: v.object({
|
|
176
178
|
topSources: v.literal("eventVolume"),
|
|
179
|
+
topCampaigns: v.literal("eventVolume"),
|
|
177
180
|
topPages: v.literal("eventVolume"),
|
|
178
181
|
topCountries: v.literal("eventVolume"),
|
|
179
182
|
topDevices: v.literal("eventVolume"),
|
|
@@ -187,7 +190,7 @@ export const overview = query({
|
|
|
187
190
|
sessionReplay: v.literal("unsupported"),
|
|
188
191
|
errorInsights: v.literal("unsupported"),
|
|
189
192
|
outboundLinks: v.literal("eventVolume"),
|
|
190
|
-
botDetection: v.literal("
|
|
193
|
+
botDetection: v.literal("userAgent"),
|
|
191
194
|
}),
|
|
192
195
|
}),
|
|
193
196
|
}),
|
|
@@ -314,6 +317,7 @@ export const overview = query({
|
|
|
314
317
|
.sort(([left], [right]) => left - right)
|
|
315
318
|
.map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
|
|
316
319
|
topSources: top("source"),
|
|
320
|
+
topCampaigns: top("campaign"),
|
|
317
321
|
topPages: top("page"),
|
|
318
322
|
topCountries: top("country"),
|
|
319
323
|
topDevices: top("device"),
|
|
@@ -330,6 +334,7 @@ export const overview = query({
|
|
|
330
334
|
},
|
|
331
335
|
breakdowns: {
|
|
332
336
|
topSources: "eventVolume" as const,
|
|
337
|
+
topCampaigns: "eventVolume" as const,
|
|
333
338
|
topPages: "eventVolume" as const,
|
|
334
339
|
topCountries: "eventVolume" as const,
|
|
335
340
|
topDevices: "eventVolume" as const,
|
|
@@ -343,7 +348,7 @@ export const overview = query({
|
|
|
343
348
|
sessionReplay: "unsupported" as const,
|
|
344
349
|
errorInsights: "unsupported" as const,
|
|
345
350
|
outboundLinks: "eventVolume" as const,
|
|
346
|
-
botDetection: "
|
|
351
|
+
botDetection: "userAgent" as const,
|
|
347
352
|
},
|
|
348
353
|
},
|
|
349
354
|
};
|
|
@@ -593,18 +598,20 @@ export const visitorJourney = query({
|
|
|
593
598
|
}
|
|
594
599
|
const rows: RawJourneyEvent[] = [];
|
|
595
600
|
for (const siteId of siteIds) {
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
601
|
+
for (const visitorId of await resolveVisitorIdentities(ctx, siteId, args.visitorId)) {
|
|
602
|
+
const cutoff = rows.length >= limit
|
|
603
|
+
? rows[limit - 1]!.timestamp
|
|
604
|
+
: args.to;
|
|
605
|
+
rows.push(...await loadVisitorJourneyEvents(ctx, {
|
|
606
|
+
siteId,
|
|
607
|
+
visitorId,
|
|
608
|
+
from: args.from,
|
|
609
|
+
to: cutoff,
|
|
610
|
+
resultLimit: limit,
|
|
611
|
+
}));
|
|
612
|
+
rows.sort(compareJourneyEvents);
|
|
613
|
+
if (rows.length > limit) rows.length = limit;
|
|
614
|
+
}
|
|
608
615
|
}
|
|
609
616
|
return hydrateEventContexts(ctx, rows);
|
|
610
617
|
},
|
|
@@ -147,7 +147,7 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
|
|
|
147
147
|
sequence: event.sequence,
|
|
148
148
|
target: event.target === undefined
|
|
149
149
|
? undefined
|
|
150
|
-
: cleanString(event.target, 160) || undefined,
|
|
150
|
+
: cleanString(event.target.replace(/\s+/g, " "), 160) || undefined,
|
|
151
151
|
href: event.href === undefined ? undefined : sanitizePublicUrl(event.href),
|
|
152
152
|
properties: sanitizeProperties(event.properties),
|
|
153
153
|
revenueCents: event.revenueCents,
|
|
@@ -236,6 +236,9 @@ export function sanitizeContext(context: IngestContext | undefined): IngestConte
|
|
|
236
236
|
browser: context?.browser ? cleanString(context.browser, 32) || undefined : undefined,
|
|
237
237
|
os: context?.os ? cleanString(context.os, 32) || undefined : undefined,
|
|
238
238
|
device: context?.device ? cleanString(context.device, 24) || undefined : undefined,
|
|
239
|
+
visitorKey: context?.visitorKey && SAFE_OPAQUE_ID.test(context.visitorKey)
|
|
240
|
+
? cleanString(context.visitorKey, 128) || undefined
|
|
241
|
+
: undefined,
|
|
239
242
|
};
|
|
240
243
|
}
|
|
241
244
|
|
package/src/component/schema.ts
CHANGED
|
@@ -283,6 +283,21 @@ export default defineSchema({
|
|
|
283
283
|
.index("by_siteId", ["siteId"])
|
|
284
284
|
.index("by_siteId_and_slug", ["siteId", "slug"]),
|
|
285
285
|
|
|
286
|
+
visitorSecrets: defineTable({
|
|
287
|
+
key: v.literal("current"),
|
|
288
|
+
value: v.string(),
|
|
289
|
+
createdAt: v.number(),
|
|
290
|
+
}).index("by_key", ["key"]),
|
|
291
|
+
|
|
292
|
+
visitorAliases: defineTable({
|
|
293
|
+
siteId: v.id("sites"),
|
|
294
|
+
visitorId: v.string(),
|
|
295
|
+
previousVisitorId: v.string(),
|
|
296
|
+
linkedAt: v.number(),
|
|
297
|
+
})
|
|
298
|
+
.index("by_siteId_and_visitorId", ["siteId", "visitorId"])
|
|
299
|
+
.index("by_siteId_and_previousVisitorId", ["siteId", "previousVisitorId"]),
|
|
300
|
+
|
|
286
301
|
visitorAttributions: defineTable({
|
|
287
302
|
siteId: v.id("sites"),
|
|
288
303
|
visitorId: v.string(),
|
|
@@ -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
|
+
}
|
|
@@ -47,6 +47,7 @@ export const ingestContextValidator = v.object({
|
|
|
47
47
|
browser: v.optional(v.string()),
|
|
48
48
|
os: v.optional(v.string()),
|
|
49
49
|
device: v.optional(v.string()),
|
|
50
|
+
visitorKey: v.optional(v.string()),
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
export const batchedEventValidator = trackerEventValidator.extend(
|
|
@@ -72,6 +73,7 @@ export const sessionFieldsValidator = v.object({
|
|
|
72
73
|
siteId: v.id("sites"),
|
|
73
74
|
sessionId: v.string(),
|
|
74
75
|
visitorId: v.string(),
|
|
76
|
+
visitorKey: v.optional(v.string()),
|
|
75
77
|
startedAt: v.number(),
|
|
76
78
|
lastSeenAt: v.number(),
|
|
77
79
|
entryPath: v.string(),
|
|
@@ -128,6 +130,7 @@ export const eventFieldsValidator = v.object({
|
|
|
128
130
|
|
|
129
131
|
export const dimensionTypeValidator = v.union(
|
|
130
132
|
v.literal("source"),
|
|
133
|
+
v.literal("campaign"),
|
|
131
134
|
v.literal("page"),
|
|
132
135
|
v.literal("country"),
|
|
133
136
|
v.literal("device"),
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { MAX_VISITOR_ALIASES } from "./constants.js";
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
import { sanitizeOpaqueId } from "./sanitize.js";
|
|
5
|
+
import type { Id } from "./_generated/dataModel.js";
|
|
6
|
+
import { mutation, type QueryCtx } from "./_generated/server.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Folds an anonymous visitor id into the pseudonymous identity the host
|
|
10
|
+
* supplies after signup or sign-in. Links are one level deep: an alias can
|
|
11
|
+
* belong to one identity, an identity can never become an alias, and an
|
|
12
|
+
* alias can never own aliases, so journeys stay bounded and unambiguous.
|
|
13
|
+
*/
|
|
14
|
+
export const link = mutation({
|
|
15
|
+
args: {
|
|
16
|
+
siteId: v.id("sites"),
|
|
17
|
+
visitorId: v.string(),
|
|
18
|
+
previousVisitorId: v.string(),
|
|
19
|
+
},
|
|
20
|
+
returns: v.object({ linked: v.boolean(), aliasCount: v.number() }),
|
|
21
|
+
handler: async (ctx, args) => {
|
|
22
|
+
const site = await ctx.db.get("sites", args.siteId);
|
|
23
|
+
if (!site) fail("NOT_FOUND", "site not found");
|
|
24
|
+
let visitorId: string;
|
|
25
|
+
let previousVisitorId: string;
|
|
26
|
+
try {
|
|
27
|
+
visitorId = sanitizeOpaqueId(args.visitorId, "visitorId");
|
|
28
|
+
previousVisitorId = sanitizeOpaqueId(
|
|
29
|
+
args.previousVisitorId,
|
|
30
|
+
"previousVisitorId",
|
|
31
|
+
);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
fail(
|
|
34
|
+
"INVALID_ARGUMENT",
|
|
35
|
+
error instanceof Error ? error.message : "invalid visitor id",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (visitorId === previousVisitorId) {
|
|
39
|
+
fail("INVALID_ARGUMENT", "previousVisitorId must differ from visitorId");
|
|
40
|
+
}
|
|
41
|
+
if (await findAlias(ctx, args.siteId, visitorId)) {
|
|
42
|
+
fail("CONFLICT", "visitorId is already linked to another visitor");
|
|
43
|
+
}
|
|
44
|
+
const previousOwnsAliases = await ctx.db
|
|
45
|
+
.query("visitorAliases")
|
|
46
|
+
.withIndex("by_siteId_and_visitorId", (range) =>
|
|
47
|
+
range.eq("siteId", args.siteId).eq("visitorId", previousVisitorId),
|
|
48
|
+
)
|
|
49
|
+
.first();
|
|
50
|
+
if (previousOwnsAliases) {
|
|
51
|
+
fail("CONFLICT", "previousVisitorId already owns linked visitors");
|
|
52
|
+
}
|
|
53
|
+
const aliases = await ctx.db
|
|
54
|
+
.query("visitorAliases")
|
|
55
|
+
.withIndex("by_siteId_and_visitorId", (range) =>
|
|
56
|
+
range.eq("siteId", args.siteId).eq("visitorId", visitorId),
|
|
57
|
+
)
|
|
58
|
+
.take(MAX_VISITOR_ALIASES);
|
|
59
|
+
const existing = await findAlias(ctx, args.siteId, previousVisitorId);
|
|
60
|
+
if (existing) {
|
|
61
|
+
if (existing.visitorId !== visitorId) {
|
|
62
|
+
fail("CONFLICT", "previousVisitorId is already linked to a different visitor");
|
|
63
|
+
}
|
|
64
|
+
return { linked: false, aliasCount: aliases.length };
|
|
65
|
+
}
|
|
66
|
+
if (aliases.length >= MAX_VISITOR_ALIASES) {
|
|
67
|
+
fail("LIMIT_EXCEEDED", `a visitor can hold at most ${MAX_VISITOR_ALIASES} aliases`, {
|
|
68
|
+
limit: MAX_VISITOR_ALIASES,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
await ctx.db.insert("visitorAliases", {
|
|
72
|
+
siteId: args.siteId,
|
|
73
|
+
visitorId,
|
|
74
|
+
previousVisitorId,
|
|
75
|
+
linkedAt: Date.now(),
|
|
76
|
+
});
|
|
77
|
+
return { linked: true, aliasCount: aliases.length + 1 };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** The identity a visitor id resolves to, followed by every alias it owns. */
|
|
82
|
+
export async function resolveVisitorIdentities(
|
|
83
|
+
ctx: QueryCtx,
|
|
84
|
+
siteId: Id<"sites">,
|
|
85
|
+
visitorId: string,
|
|
86
|
+
): Promise<string[]> {
|
|
87
|
+
const alias = await findAlias(ctx, siteId, visitorId);
|
|
88
|
+
const identity = alias?.visitorId ?? visitorId;
|
|
89
|
+
const aliases = await ctx.db
|
|
90
|
+
.query("visitorAliases")
|
|
91
|
+
.withIndex("by_siteId_and_visitorId", (range) =>
|
|
92
|
+
range.eq("siteId", siteId).eq("visitorId", identity),
|
|
93
|
+
)
|
|
94
|
+
.take(MAX_VISITOR_ALIASES);
|
|
95
|
+
return [identity, ...aliases.map((row) => row.previousVisitorId)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function findAlias(ctx: QueryCtx, siteId: Id<"sites">, previousVisitorId: string) {
|
|
99
|
+
return ctx.db
|
|
100
|
+
.query("visitorAliases")
|
|
101
|
+
.withIndex("by_siteId_and_previousVisitorId", (range) =>
|
|
102
|
+
range.eq("siteId", siteId).eq("previousVisitorId", previousVisitorId),
|
|
103
|
+
)
|
|
104
|
+
.unique();
|
|
105
|
+
}
|