@iann29/rastro 0.4.0 → 0.6.0

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 (112) hide show
  1. package/README.md +146 -24
  2. package/agent/integration.md +66 -29
  3. package/agent/manifest.json +19 -8
  4. package/agent/manifest.schema.json +28 -9
  5. package/dist/client/federation.d.ts +38 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +18 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/identity.d.ts +11 -0
  10. package/dist/client/identity.d.ts.map +1 -0
  11. package/dist/client/identity.js +123 -0
  12. package/dist/client/identity.js.map +1 -0
  13. package/dist/client/index.d.ts +501 -11
  14. package/dist/client/index.d.ts.map +1 -1
  15. package/dist/client/index.js +220 -4
  16. package/dist/client/index.js.map +1 -1
  17. package/dist/component/_generated/api.d.ts +8 -0
  18. package/dist/component/_generated/api.d.ts.map +1 -1
  19. package/dist/component/_generated/api.js.map +1 -1
  20. package/dist/component/_generated/component.d.ts +158 -1
  21. package/dist/component/_generated/component.d.ts.map +1 -1
  22. package/dist/component/constants.d.ts +2 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +7 -0
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +1 -0
  27. package/dist/component/coverage.d.ts.map +1 -1
  28. package/dist/component/coverage.js +6 -1
  29. package/dist/component/coverage.js.map +1 -1
  30. package/dist/component/eventStore.d.ts +2 -0
  31. package/dist/component/eventStore.d.ts.map +1 -1
  32. package/dist/component/http.d.ts.map +1 -1
  33. package/dist/component/http.js +51 -1
  34. package/dist/component/http.js.map +1 -1
  35. package/dist/component/ingest.d.ts +2 -0
  36. package/dist/component/ingest.d.ts.map +1 -1
  37. package/dist/component/ingest.js +72 -22
  38. package/dist/component/ingest.js.map +1 -1
  39. package/dist/component/origin.d.ts +70 -0
  40. package/dist/component/origin.d.ts.map +1 -0
  41. package/dist/component/origin.js +230 -0
  42. package/dist/component/origin.js.map +1 -0
  43. package/dist/component/people.d.ts +79 -0
  44. package/dist/component/people.d.ts.map +1 -0
  45. package/dist/component/people.js +249 -0
  46. package/dist/component/people.js.map +1 -0
  47. package/dist/component/platforms.d.ts +33 -0
  48. package/dist/component/platforms.d.ts.map +1 -0
  49. package/dist/component/platforms.js +328 -0
  50. package/dist/component/platforms.js.map +1 -0
  51. package/dist/component/reports.d.ts +35 -74
  52. package/dist/component/reports.d.ts.map +1 -1
  53. package/dist/component/reports.js +59 -14
  54. package/dist/component/reports.js.map +1 -1
  55. package/dist/component/sanitize.d.ts +5 -0
  56. package/dist/component/sanitize.d.ts.map +1 -1
  57. package/dist/component/sanitize.js +33 -1
  58. package/dist/component/sanitize.js.map +1 -1
  59. package/dist/component/schema.d.ts +101 -7
  60. package/dist/component/schema.js +45 -1
  61. package/dist/component/schema.js.map +1 -1
  62. package/dist/component/trackedLinks.d.ts +91 -0
  63. package/dist/component/trackedLinks.d.ts.map +1 -0
  64. package/dist/component/trackedLinks.js +314 -0
  65. package/dist/component/trackedLinks.js.map +1 -0
  66. package/dist/component/useragent.d.ts +6 -0
  67. package/dist/component/useragent.d.ts.map +1 -1
  68. package/dist/component/useragent.js +9 -0
  69. package/dist/component/useragent.js.map +1 -1
  70. package/dist/component/validators.d.ts +101 -11
  71. package/dist/component/validators.d.ts.map +1 -1
  72. package/dist/component/validators.js +62 -1
  73. package/dist/component/validators.js.map +1 -1
  74. package/dist/component/visitors.d.ts +38 -2
  75. package/dist/component/visitors.d.ts.map +1 -1
  76. package/dist/component/visitors.js +162 -42
  77. package/dist/component/visitors.js.map +1 -1
  78. package/dist/react/index.d.ts +9 -5
  79. package/dist/react/index.d.ts.map +1 -1
  80. package/dist/react/index.js +36 -5
  81. package/dist/react/index.js.map +1 -1
  82. package/dist/tracker/generated.d.ts +6 -6
  83. package/dist/tracker/generated.d.ts.map +1 -1
  84. package/dist/tracker/generated.js +6 -6
  85. package/dist/tracker/generated.js.map +1 -1
  86. package/dist/tracker/tracker.d.ts +3 -1
  87. package/dist/tracker/tracker.d.ts.map +1 -1
  88. package/dist/tracker/tracker.js +56 -5
  89. package/dist/tracker/tracker.js.map +1 -1
  90. package/dist/tracker.min.js +1 -1
  91. package/docs/federation.md +24 -0
  92. package/docs/identity.md +307 -0
  93. package/docs/upgrading.md +190 -19
  94. package/llms.txt +11 -2
  95. package/package.json +5 -3
  96. package/src/component/_generated/api.ts +8 -0
  97. package/src/component/_generated/component.ts +251 -1
  98. package/src/component/constants.ts +7 -0
  99. package/src/component/coverage.ts +7 -1
  100. package/src/component/http.ts +56 -1
  101. package/src/component/ingest.ts +100 -28
  102. package/src/component/origin.ts +273 -0
  103. package/src/component/people.ts +321 -0
  104. package/src/component/platforms.ts +359 -0
  105. package/src/component/reports.ts +84 -13
  106. package/src/component/sanitize.ts +39 -1
  107. package/src/component/schema.ts +53 -0
  108. package/src/component/trackedLinks.ts +384 -0
  109. package/src/component/useragent.ts +11 -0
  110. package/src/component/validators.ts +120 -0
  111. package/src/component/visitors.ts +232 -55
  112. package/src/tracker/generated.ts +6 -6
@@ -55,6 +55,8 @@ import {
55
55
  type RawJourneyEvent,
56
56
  } from "./eventStore.js";
57
57
  import { isPlainRecord } from "./guards.js";
58
+ import { classifyOrigin } from "./origin.js";
59
+ import { externalReferrer } from "./sanitize.js";
58
60
  import {
59
61
  affiliateFieldsValidator,
60
62
  eventFieldsValidator,
@@ -62,8 +64,9 @@ import {
62
64
  goalFieldsValidator,
63
65
  sessionFieldsValidator,
64
66
  vitalMetricValidator,
67
+ visitorProfileValidator,
65
68
  } from "./validators.js";
66
- import { resolveVisitorIdentities } from "./visitors.js";
69
+ import { resolveVisitorIdentities, withVisitorProfiles } from "./visitors.js";
67
70
  import {
68
71
  mergeVitalHistograms,
69
72
  VITAL_ALL,
@@ -74,6 +77,7 @@ import {
74
77
  } from "./vitals.js";
75
78
 
76
79
  const sessionDocumentValidator = sessionFieldsValidator.extend({
80
+ profile: v.optional(visitorProfileValidator),
77
81
  _id: v.id("sessions"),
78
82
  _creationTime: v.number(),
79
83
  });
@@ -82,6 +86,7 @@ const eventDocumentValidator = eventFieldsValidator.extend({
82
86
  _creationTime: v.number(),
83
87
  });
84
88
  const liveSessionDocumentValidator = v.object({
89
+ profile: v.optional(visitorProfileValidator),
85
90
  _id: v.id("liveSessions"),
86
91
  _creationTime: v.number(),
87
92
  siteId: v.id("sites"),
@@ -220,6 +225,11 @@ export const overview = query({
220
225
  topMediums: v.array(topItemValidator),
221
226
  // Destination hosts of outbound clicks, ranked by clicks.
222
227
  topOutbound: v.array(topItemValidator),
228
+ // The session's origin (origin.ts), ranked like sources: platform ids,
229
+ // channel ids, and the evidence that decided them.
230
+ topPlatforms: v.array(topItemValidator),
231
+ topChannels: v.array(topItemValidator),
232
+ topEvidence: v.array(topItemValidator),
223
233
  metadata: v.object({
224
234
  visitors: v.object({
225
235
  basis: v.union(
@@ -242,6 +252,9 @@ export const overview = query({
242
252
  topGoals: v.literal("goalCompletionVolume"),
243
253
  topMediums: breakdownBasisValidator,
244
254
  topOutbound: v.literal("eventVolume"),
255
+ topPlatforms: breakdownBasisValidator,
256
+ topChannels: breakdownBasisValidator,
257
+ topEvidence: breakdownBasisValidator,
245
258
  }),
246
259
  // Since when every site in the request records the medium and outbound
247
260
  // slots; null while one of them has not stamped it. A range that starts
@@ -536,6 +549,9 @@ export const overview = query({
536
549
  topGoals: top("goal", "eventVolume"),
537
550
  topMediums: top("medium", sessionBasis),
538
551
  topOutbound: top("outbound", "eventVolume"),
552
+ topPlatforms: top("platform", sessionBasis),
553
+ topChannels: top("channel", sessionBasis),
554
+ topEvidence: top("evidence", sessionBasis),
539
555
  metadata: {
540
556
  visitors: {
541
557
  basis: visitorBasis,
@@ -555,6 +571,9 @@ export const overview = query({
555
571
  topGoals: "goalCompletionVolume" as const,
556
572
  topMediums: sessionBasis,
557
573
  topOutbound: "eventVolume" as const,
574
+ topPlatforms: sessionBasis,
575
+ topChannels: sessionBasis,
576
+ topEvidence: sessionBasis,
558
577
  },
559
578
  dimensionsSince: controls.every(
560
579
  (control) => control?.dimensionsSince !== undefined,
@@ -612,7 +631,7 @@ export const liveVisitors = query({
612
631
  .take(limit)),
613
632
  );
614
633
  }
615
- return rows
634
+ const visible = rows
616
635
  .sort((left, right) => right.lastSeenAt - left.lastSeenAt)
617
636
  .slice(0, limit)
618
637
  .map((row) => ({
@@ -646,6 +665,7 @@ export const liveVisitors = query({
646
665
  : {}),
647
666
  ...(row.funnel ? { funnel: row.funnel } : {}),
648
667
  }));
668
+ return withVisitorProfiles(ctx, visible);
649
669
  },
650
670
  });
651
671
 
@@ -948,6 +968,27 @@ async function readRouteRollups(
948
968
  return { rows, completeFrom: cutAt + DAY_MS };
949
969
  }
950
970
 
971
+ type SessionRow = Omit<
972
+ Doc<"sessions">,
973
+ "geoLookupAttemptedAt" | "lastPageviewAt"
974
+ >;
975
+
976
+ /**
977
+ * A session recorded before origins existed, classified on read from what it
978
+ * kept (utm fields, referrer, affiliate) and marked `legacy`: it carries no
979
+ * click IDs or in-app signal, and it never enters the aggregates.
980
+ */
981
+ function withOrigin(session: SessionRow, domains: string[]): SessionRow {
982
+ if (session.platform !== undefined) return session;
983
+ const origin = classifyOrigin({
984
+ utmSource: session.utmSource,
985
+ utmMedium: session.utmMedium,
986
+ referrer: externalReferrer(session.referrer, domains),
987
+ affiliate: session.affiliateSlug !== undefined,
988
+ });
989
+ return { ...session, ...origin, evidence: "legacy" };
990
+ }
991
+
951
992
  /** Metadata for a selected session, independent of list pagination. */
952
993
  export const getSession = query({
953
994
  args: { siteId: v.id("sites"), sessionId: v.string() },
@@ -965,7 +1006,10 @@ export const getSession = query({
965
1006
  lastPageviewAt: _pageview,
966
1007
  ...result
967
1008
  } = session;
968
- return result;
1009
+ const site = await ctx.db.get("sites", args.siteId);
1010
+ return (
1011
+ await withVisitorProfiles(ctx, [withOrigin(result, site?.domains ?? [])])
1012
+ )[0];
969
1013
  },
970
1014
  });
971
1015
 
@@ -1104,17 +1148,19 @@ export const listSessions = query({
1104
1148
  .take(take)),
1105
1149
  );
1106
1150
  }
1151
+ const site = await ctx.db.get("sites", args.siteId);
1107
1152
  const publicRows = rows.map(
1108
1153
  ({
1109
1154
  geoLookupAttemptedAt: _attemptedAt,
1110
1155
  lastPageviewAt: _lastPageviewAt,
1111
1156
  ...row
1112
- }) => row,
1157
+ }) => withOrigin(row, site?.domains ?? []),
1113
1158
  );
1114
- return keysetPaginationResult(publicRows, pagination, (row) => ({
1159
+ const result = keysetPaginationResult(publicRows, pagination, (row) => ({
1115
1160
  timestamp: row.lastSeenAt,
1116
1161
  creationTime: row._creationTime,
1117
1162
  }));
1163
+ return { ...result, page: await withVisitorProfiles(ctx, result.page) };
1118
1164
  },
1119
1165
  });
1120
1166
 
@@ -1826,6 +1872,7 @@ const coverageDatasetValidator = v.union(
1826
1872
  v.literal("affiliates"),
1827
1873
  v.literal("vitals"),
1828
1874
  v.literal("siteMap"),
1875
+ v.literal("origin"),
1829
1876
  );
1830
1877
 
1831
1878
  export const dataCoverage = query({
@@ -1859,6 +1906,7 @@ export const dataCoverage = query({
1859
1906
  "affiliates",
1860
1907
  "vitals",
1861
1908
  "siteMap",
1909
+ "origin",
1862
1910
  ] as const;
1863
1911
  return {
1864
1912
  siteId: args.siteId,
@@ -2163,7 +2211,26 @@ async function datasetCoverageForRange(
2163
2211
  to: number,
2164
2212
  ) {
2165
2213
  if (dataset === "siteMap") {
2166
- return siteMapDatasetCoverage(ctx, siteId, from, to);
2214
+ return stampedDatasetCoverage(
2215
+ ctx,
2216
+ siteId,
2217
+ from,
2218
+ to,
2219
+ "siteMapSince",
2220
+ siteMapCoverageDataset,
2221
+ );
2222
+ }
2223
+ // Origins exist in sessions and aggregate slots from the site's stamp, and
2224
+ // the overview reads them from the daily aggregates retention trims.
2225
+ if (dataset === "origin") {
2226
+ return stampedDatasetCoverage(
2227
+ ctx,
2228
+ siteId,
2229
+ from,
2230
+ to,
2231
+ "originSince",
2232
+ "overviewDay",
2233
+ );
2167
2234
  }
2168
2235
  // Vitals have no legacy fallback source; the rollup either covers the range
2169
2236
  // or the coverage states say how much of it exists.
@@ -2205,23 +2272,26 @@ async function datasetCoverageForRange(
2205
2272
  }
2206
2273
 
2207
2274
  /**
2208
- * The site map's coverage without reading its rows: it starts at the site's
2209
- * `siteMapSince` and ends where retention cut it. The report itself may still
2210
- * narrow the window when the range exceeds its row budget.
2275
+ * A stamped dataset's coverage without reading its rows: it starts at the
2276
+ * site's stamp (`siteMapSince`, `originSince`) and ends where retention cut
2277
+ * it. The site map report may still narrow the window when the range exceeds
2278
+ * its row budget.
2211
2279
  */
2212
- async function siteMapDatasetCoverage(
2280
+ async function stampedDatasetCoverage(
2213
2281
  ctx: QueryCtx,
2214
2282
  siteId: Id<"sites">,
2215
2283
  from: number,
2216
2284
  to: number,
2285
+ stamp: "siteMapSince" | "originSince",
2286
+ retainedDataset: string,
2217
2287
  ) {
2218
2288
  const control = await ctx.db
2219
2289
  .query("analyticsCoverage")
2220
2290
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
2221
2291
  .unique();
2222
- const since = control?.siteMapSince ?? null;
2292
+ const since = control?.[stamp] ?? null;
2223
2293
  const retainedBefore =
2224
- control?.retained.find((item) => item.dataset === siteMapCoverageDataset)
2294
+ control?.retained.find((item) => item.dataset === retainedDataset)
2225
2295
  ?.before ?? null;
2226
2296
  const unavailable =
2227
2297
  since === null ||
@@ -2252,7 +2322,8 @@ type PublicCoverageDataset =
2252
2322
  | "funnels"
2253
2323
  | "affiliates"
2254
2324
  | "vitals"
2255
- | "siteMap";
2325
+ | "siteMap"
2326
+ | "origin";
2256
2327
 
2257
2328
  type CoverageSource =
2258
2329
  | "overviewHour"
@@ -10,6 +10,7 @@ import type {
10
10
  TrackerEvent,
11
11
  } from "./validators.js";
12
12
  import { isValidVitalValue, isVitalMetric } from "./vitals.js";
13
+ import { isPlatform, knownClickIds } from "./origin.js";
13
14
 
14
15
  const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
15
16
  const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
@@ -121,6 +122,27 @@ export function sanitizePublicUrl(value: string): string | undefined {
121
122
  }
122
123
  }
123
124
 
125
+ const ANDROID_PACKAGE = /^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+$/;
126
+
127
+ /**
128
+ * A referrer as sanitizePublicUrl keeps it, or the Android app that opened
129
+ * the link: `android-app://<package>/…` keeps the package alone.
130
+ */
131
+ function sanitizeReferrer(value: string): string | undefined {
132
+ const input = cleanString(value, 2048);
133
+ if (!input.toLowerCase().startsWith("android-app://")) {
134
+ return sanitizePublicUrl(input);
135
+ }
136
+ try {
137
+ const pkg = new URL(input).hostname.toLowerCase();
138
+ return pkg.length <= 120 && ANDROID_PACKAGE.test(pkg)
139
+ ? `android-app://${pkg}`
140
+ : undefined;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
124
146
  /**
125
147
  * The host of a public URL the way the outbound dimension names it: lowercase,
126
148
  * without port, path, or query. A relative path has no host.
@@ -229,7 +251,7 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
229
251
  referrer:
230
252
  event.referrer === undefined
231
253
  ? undefined
232
- : sanitizePublicUrl(event.referrer),
254
+ : sanitizeReferrer(event.referrer),
233
255
  timestamp: Math.trunc(event.timestamp),
234
256
  sequence: event.sequence,
235
257
  target:
@@ -246,6 +268,7 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
246
268
  event.type === "vital" && event.value !== undefined
247
269
  ? Math.round(event.value)
248
270
  : undefined,
271
+ clid: event.clid === undefined ? undefined : knownClickIds(event.clid),
249
272
  };
250
273
  }
251
274
 
@@ -353,6 +376,8 @@ export function sanitizeContext(
353
376
  context?.visitorKey && SAFE_OPAQUE_ID.test(context.visitorKey)
354
377
  ? cleanString(context.visitorKey, 128) || undefined
355
378
  : undefined,
379
+ inApp:
380
+ context?.inApp && isPlatform(context.inApp) ? context.inApp : undefined,
356
381
  };
357
382
  }
358
383
 
@@ -393,6 +418,19 @@ export function sourceFromReferrer(referrer: string | undefined): string {
393
418
  }
394
419
  }
395
420
 
421
+ /**
422
+ * The referrer unless it is internal: a relative path or one of the site's
423
+ * own domains never names where a session came from.
424
+ */
425
+ export function externalReferrer(
426
+ referrer: string | undefined,
427
+ domains: string[],
428
+ ): string | undefined {
429
+ if (!referrer || referrer.startsWith("/") || originAllowed(referrer, domains))
430
+ return undefined;
431
+ return referrer;
432
+ }
433
+
396
434
  export function stableHash(value: string): number {
397
435
  let hash = 2_166_136_261;
398
436
  for (let index = 0; index < value.length; index += 1) {
@@ -8,7 +8,10 @@ import {
8
8
  goalFieldsValidator,
9
9
  sessionFieldsValidator,
10
10
  siteFieldsValidator,
11
+ trackedLinkFieldsValidator,
11
12
  visitorSketchValidator,
13
+ visitorProfileValidator,
14
+ personAttributeValueValidator,
12
15
  vitalMetricValidator,
13
16
  } from "./validators.js";
14
17
  import {
@@ -268,6 +271,8 @@ export default defineSchema({
268
271
  localDaySince: v.optional(v.number()),
269
272
  // Since when the medium and outbound dimension slots are recorded.
270
273
  dimensionsSince: v.optional(v.number()),
274
+ // Since when sessions carry their origin and the origin slots exist.
275
+ originSince: v.optional(v.number()),
271
276
  retained: v.array(v.object({ dataset: v.string(), before: v.number() })),
272
277
  updatedAt: v.number(),
273
278
  }).index("by_siteId", ["siteId"]),
@@ -371,12 +376,60 @@ export default defineSchema({
371
376
  .index("by_siteId", ["siteId"])
372
377
  .index("by_siteId_and_slug", ["siteId", "slug"]),
373
378
 
379
+ // Unique by slug across the deployment: the redirect route names no site.
380
+ trackedLinks: defineTable(trackedLinkFieldsValidator)
381
+ .index("by_siteId", ["siteId"])
382
+ .index("by_slug", ["slug"]),
383
+
384
+ // One row per link, UTC day and shard, plus each shard's all-time row
385
+ // under dayStart 0 (see trackedLinks.ts).
386
+ trackedLinkClicks: defineTable({
387
+ linkId: v.id("trackedLinks"),
388
+ dayStart: v.number(),
389
+ shard: v.number(),
390
+ clicks: v.number(),
391
+ bots: v.number(),
392
+ }).index("by_linkId_and_dayStart_and_shard", ["linkId", "dayStart", "shard"]),
393
+
374
394
  visitorSecrets: defineTable({
375
395
  key: v.literal("current"),
376
396
  value: v.string(),
377
397
  createdAt: v.number(),
378
398
  }).index("by_key", ["key"]),
379
399
 
400
+ visitorProfiles: defineTable(
401
+ visitorProfileValidator.extend({
402
+ siteId: v.id("sites"),
403
+ searchName: v.optional(v.string()),
404
+ searchEmail: v.optional(v.string()),
405
+ }),
406
+ )
407
+ .index("by_siteId_and_visitorId", ["siteId", "visitorId"])
408
+ .index("by_siteId_and_searchName_and_visitorId", [
409
+ "siteId",
410
+ "searchName",
411
+ "visitorId",
412
+ ])
413
+ .index("by_siteId_and_searchEmail_and_visitorId", [
414
+ "siteId",
415
+ "searchEmail",
416
+ "visitorId",
417
+ ]),
418
+
419
+ visitorAttributes: defineTable({
420
+ siteId: v.id("sites"),
421
+ visitorId: v.string(),
422
+ key: v.string(),
423
+ value: personAttributeValueValidator,
424
+ })
425
+ .index("by_siteId_and_key_and_value_and_visitorId", [
426
+ "siteId",
427
+ "key",
428
+ "value",
429
+ "visitorId",
430
+ ])
431
+ .index("by_siteId_and_visitorId", ["siteId", "visitorId"]),
432
+
380
433
  visitorAliases: defineTable({
381
434
  siteId: v.id("sites"),
382
435
  visitorId: v.string(),