@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.
Files changed (71) hide show
  1. package/README.md +128 -29
  2. package/agent/manifest.json +1 -1
  3. package/dist/client/index.d.ts +69 -11
  4. package/dist/client/index.d.ts.map +1 -1
  5. package/dist/client/index.js +19 -1
  6. package/dist/client/index.js.map +1 -1
  7. package/dist/component/_generated/api.d.ts +6 -0
  8. package/dist/component/_generated/api.d.ts.map +1 -1
  9. package/dist/component/_generated/api.js.map +1 -1
  10. package/dist/component/_generated/component.d.ts +19 -1
  11. package/dist/component/_generated/component.d.ts.map +1 -1
  12. package/dist/component/constants.d.ts +1 -0
  13. package/dist/component/constants.d.ts.map +1 -1
  14. package/dist/component/constants.js +1 -0
  15. package/dist/component/constants.js.map +1 -1
  16. package/dist/component/eventStore.d.ts +1 -0
  17. package/dist/component/eventStore.d.ts.map +1 -1
  18. package/dist/component/http.d.ts.map +1 -1
  19. package/dist/component/http.js +40 -29
  20. package/dist/component/http.js.map +1 -1
  21. package/dist/component/identity.d.ts +13 -0
  22. package/dist/component/identity.d.ts.map +1 -0
  23. package/dist/component/identity.js +58 -0
  24. package/dist/component/identity.js.map +1 -0
  25. package/dist/component/ingest.d.ts +1 -0
  26. package/dist/component/ingest.d.ts.map +1 -1
  27. package/dist/component/ingest.js +25 -6
  28. package/dist/component/ingest.js.map +1 -1
  29. package/dist/component/reports.d.ts +10 -1
  30. package/dist/component/reports.d.ts.map +1 -1
  31. package/dist/component/reports.js +22 -15
  32. package/dist/component/reports.js.map +1 -1
  33. package/dist/component/sanitize.d.ts.map +1 -1
  34. package/dist/component/sanitize.js +4 -1
  35. package/dist/component/sanitize.js.map +1 -1
  36. package/dist/component/schema.d.ts +37 -6
  37. package/dist/component/schema.js +13 -0
  38. package/dist/component/schema.js.map +1 -1
  39. package/dist/component/useragent.d.ts +9 -0
  40. package/dist/component/useragent.d.ts.map +1 -0
  41. package/dist/component/useragent.js +152 -0
  42. package/dist/component/useragent.js.map +1 -0
  43. package/dist/component/validators.d.ts +16 -10
  44. package/dist/component/validators.d.ts.map +1 -1
  45. package/dist/component/validators.js +3 -1
  46. package/dist/component/validators.js.map +1 -1
  47. package/dist/component/visitors.d.ts +19 -0
  48. package/dist/component/visitors.d.ts.map +1 -0
  49. package/dist/component/visitors.js +86 -0
  50. package/dist/component/visitors.js.map +1 -0
  51. package/dist/tracker/generated.d.ts +4 -4
  52. package/dist/tracker/generated.d.ts.map +1 -1
  53. package/dist/tracker/generated.js +4 -4
  54. package/dist/tracker/generated.js.map +1 -1
  55. package/dist/tracker/tracker.js +23 -11
  56. package/dist/tracker/tracker.js.map +1 -1
  57. package/dist/tracker.min.js +1 -1
  58. package/docs/federation.md +1 -1
  59. package/package.json +12 -8
  60. package/src/component/_generated/api.ts +6 -0
  61. package/src/component/_generated/component.ts +18 -1
  62. package/src/component/constants.ts +1 -0
  63. package/src/component/http.ts +55 -34
  64. package/src/component/identity.ts +74 -0
  65. package/src/component/ingest.ts +29 -3
  66. package/src/component/reports.ts +21 -14
  67. package/src/component/sanitize.ts +4 -1
  68. package/src/component/schema.ts +15 -0
  69. package/src/component/useragent.ts +171 -0
  70. package/src/component/validators.ts +3 -0
  71. package/src/component/visitors.ts +105 -0
@@ -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
- const source = sourceFromReferrer(event.referrer);
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, event.visitorId);
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, event.visitorId);
2105
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2080
2106
  }
2081
2107
  delta.conversions += Number(financialConversion);
2082
2108
  delta.revenueCents += revenueCents;
@@ -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("unsupported"),
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: "unsupported" as const,
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 cutoff = rows.length >= limit
597
- ? rows[limit - 1]!.timestamp
598
- : args.to;
599
- rows.push(...await loadVisitorJourneyEvents(ctx, {
600
- siteId,
601
- visitorId: args.visitorId,
602
- from: args.from,
603
- to: cutoff,
604
- resultLimit: limit,
605
- }));
606
- rows.sort(compareJourneyEvents);
607
- if (rows.length > limit) rows.length = limit;
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
 
@@ -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
+ }