@iann29/rastro 0.3.0 → 0.5.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 (128) hide show
  1. package/README.md +199 -50
  2. package/agent/integration.md +26 -10
  3. package/agent/manifest.json +10 -5
  4. package/agent/manifest.schema.json +14 -5
  5. package/dist/client/federation.d.ts +28 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +12 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +262 -9
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +119 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +6 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +72 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +1 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/constants.d.ts +9 -2
  22. package/dist/component/constants.d.ts.map +1 -1
  23. package/dist/component/constants.js +31 -3
  24. package/dist/component/constants.js.map +1 -1
  25. package/dist/component/convex.config.d.ts +1 -0
  26. package/dist/component/convex.config.js +3 -0
  27. package/dist/component/convex.config.js.map +1 -1
  28. package/dist/component/coverage.d.ts +1 -0
  29. package/dist/component/coverage.d.ts.map +1 -1
  30. package/dist/component/coverage.js +6 -1
  31. package/dist/component/coverage.js.map +1 -1
  32. package/dist/component/eventStore.d.ts +2 -0
  33. package/dist/component/eventStore.d.ts.map +1 -1
  34. package/dist/component/geo.d.ts +1 -0
  35. package/dist/component/geo.d.ts.map +1 -1
  36. package/dist/component/geo.js +1 -1
  37. package/dist/component/geo.js.map +1 -1
  38. package/dist/component/http.d.ts.map +1 -1
  39. package/dist/component/http.js +65 -2
  40. package/dist/component/http.js.map +1 -1
  41. package/dist/component/identity.d.ts +12 -0
  42. package/dist/component/identity.d.ts.map +1 -1
  43. package/dist/component/identity.js +30 -2
  44. package/dist/component/identity.js.map +1 -1
  45. package/dist/component/ingest.d.ts +36 -0
  46. package/dist/component/ingest.d.ts.map +1 -1
  47. package/dist/component/ingest.js +111 -20
  48. package/dist/component/ingest.js.map +1 -1
  49. package/dist/component/live.d.ts.map +1 -1
  50. package/dist/component/live.js +34 -4
  51. package/dist/component/live.js.map +1 -1
  52. package/dist/component/origin.d.ts +70 -0
  53. package/dist/component/origin.d.ts.map +1 -0
  54. package/dist/component/origin.js +230 -0
  55. package/dist/component/origin.js.map +1 -0
  56. package/dist/component/platforms.d.ts +33 -0
  57. package/dist/component/platforms.d.ts.map +1 -0
  58. package/dist/component/platforms.js +328 -0
  59. package/dist/component/platforms.js.map +1 -0
  60. package/dist/component/reports.d.ts +27 -72
  61. package/dist/component/reports.d.ts.map +1 -1
  62. package/dist/component/reports.js +72 -16
  63. package/dist/component/reports.js.map +1 -1
  64. package/dist/component/rollupStore.d.ts +7 -4
  65. package/dist/component/rollupStore.d.ts.map +1 -1
  66. package/dist/component/rollupStore.js +37 -17
  67. package/dist/component/rollupStore.js.map +1 -1
  68. package/dist/component/rollups.d.ts +27 -8
  69. package/dist/component/rollups.d.ts.map +1 -1
  70. package/dist/component/rollups.js +110 -32
  71. package/dist/component/rollups.js.map +1 -1
  72. package/dist/component/sanitize.d.ts +5 -0
  73. package/dist/component/sanitize.d.ts.map +1 -1
  74. package/dist/component/sanitize.js +33 -1
  75. package/dist/component/sanitize.js.map +1 -1
  76. package/dist/component/schema.d.ts +68 -9
  77. package/dist/component/schema.js +21 -3
  78. package/dist/component/schema.js.map +1 -1
  79. package/dist/component/trackedLinks.d.ts +91 -0
  80. package/dist/component/trackedLinks.d.ts.map +1 -0
  81. package/dist/component/trackedLinks.js +314 -0
  82. package/dist/component/trackedLinks.js.map +1 -0
  83. package/dist/component/useragent.d.ts +6 -0
  84. package/dist/component/useragent.d.ts.map +1 -1
  85. package/dist/component/useragent.js +9 -0
  86. package/dist/component/useragent.js.map +1 -1
  87. package/dist/component/validators.d.ts +51 -11
  88. package/dist/component/validators.d.ts.map +1 -1
  89. package/dist/component/validators.js +35 -1
  90. package/dist/component/validators.js.map +1 -1
  91. package/dist/tracker/generated.d.ts +11 -11
  92. package/dist/tracker/generated.d.ts.map +1 -1
  93. package/dist/tracker/generated.js +11 -11
  94. package/dist/tracker/generated.js.map +1 -1
  95. package/dist/tracker/tracker.d.ts +1 -0
  96. package/dist/tracker/tracker.d.ts.map +1 -1
  97. package/dist/tracker/tracker.js +104 -47
  98. package/dist/tracker/tracker.js.map +1 -1
  99. package/dist/tracker/vitals.d.ts.map +1 -1
  100. package/dist/tracker/vitals.js +22 -4
  101. package/dist/tracker/vitals.js.map +1 -1
  102. package/dist/tracker.min.js +1 -1
  103. package/dist/vitals.min.js +1 -1
  104. package/docs/upgrading.md +234 -45
  105. package/llms.txt +9 -4
  106. package/package.json +2 -2
  107. package/src/component/_generated/api.ts +6 -0
  108. package/src/component/_generated/component.ts +153 -1
  109. package/src/component/_generated/server.ts +1 -0
  110. package/src/component/constants.ts +31 -3
  111. package/src/component/convex.config.ts +3 -0
  112. package/src/component/coverage.ts +7 -1
  113. package/src/component/geo.ts +1 -1
  114. package/src/component/http.ts +71 -2
  115. package/src/component/identity.ts +41 -2
  116. package/src/component/ingest.ts +146 -33
  117. package/src/component/live.ts +51 -4
  118. package/src/component/origin.ts +273 -0
  119. package/src/component/platforms.ts +359 -0
  120. package/src/component/reports.ts +98 -18
  121. package/src/component/rollupStore.ts +51 -20
  122. package/src/component/rollups.ts +141 -32
  123. package/src/component/sanitize.ts +39 -1
  124. package/src/component/schema.ts +24 -3
  125. package/src/component/trackedLinks.ts +384 -0
  126. package/src/component/useragent.ts +11 -0
  127. package/src/component/validators.ts +71 -0
  128. package/src/tracker/generated.ts +11 -11
@@ -8,6 +8,7 @@ import {
8
8
  goalFieldsValidator,
9
9
  sessionFieldsValidator,
10
10
  siteFieldsValidator,
11
+ trackedLinkFieldsValidator,
11
12
  visitorSketchValidator,
12
13
  vitalMetricValidator,
13
14
  } from "./validators.js";
@@ -248,10 +249,11 @@ export default defineSchema({
248
249
  .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
249
250
 
250
251
  // Rollup deltas wait here between ingestion and the fold job; see
251
- // rollupStore.ts. Rows are deleted as they are folded.
252
+ // rollups.ts. Rows are deleted as they are folded; a batch that keeps
253
+ // failing is set aside with `deadLetteredAt` and skipped.
252
254
  reportRollupQueue: defineTable(reportRollupQueueFields).index(
253
- "by_siteId_and_createdAt",
254
- ["siteId", "createdAt"],
255
+ "by_siteId_and_deadLetteredAt_and_createdAt",
256
+ ["siteId", "deadLetteredAt", "createdAt"],
255
257
  ),
256
258
 
257
259
  rollupFoldStates: defineTable(rollupFoldStateFields).index("by_siteId", [
@@ -267,6 +269,8 @@ export default defineSchema({
267
269
  localDaySince: v.optional(v.number()),
268
270
  // Since when the medium and outbound dimension slots are recorded.
269
271
  dimensionsSince: v.optional(v.number()),
272
+ // Since when sessions carry their origin and the origin slots exist.
273
+ originSince: v.optional(v.number()),
270
274
  retained: v.array(v.object({ dataset: v.string(), before: v.number() })),
271
275
  updatedAt: v.number(),
272
276
  }).index("by_siteId", ["siteId"]),
@@ -370,6 +374,21 @@ export default defineSchema({
370
374
  .index("by_siteId", ["siteId"])
371
375
  .index("by_siteId_and_slug", ["siteId", "slug"]),
372
376
 
377
+ // Unique by slug across the deployment: the redirect route names no site.
378
+ trackedLinks: defineTable(trackedLinkFieldsValidator)
379
+ .index("by_siteId", ["siteId"])
380
+ .index("by_slug", ["slug"]),
381
+
382
+ // One row per link, UTC day and shard, plus each shard's all-time row
383
+ // under dayStart 0 (see trackedLinks.ts).
384
+ trackedLinkClicks: defineTable({
385
+ linkId: v.id("trackedLinks"),
386
+ dayStart: v.number(),
387
+ shard: v.number(),
388
+ clicks: v.number(),
389
+ bots: v.number(),
390
+ }).index("by_linkId_and_dayStart_and_shard", ["linkId", "dayStart", "shard"]),
391
+
373
392
  visitorSecrets: defineTable({
374
393
  key: v.literal("current"),
375
394
  value: v.string(),
@@ -441,6 +460,8 @@ export default defineSchema({
441
460
 
442
461
  ingestWindows: defineTable({
443
462
  siteId: v.id("sites"),
463
+ // A session ID, or `~client:` plus a salted daily client-network key for
464
+ // the per-client budget; no sanitized session ID starts with `~`.
444
465
  sessionId: v.string(),
445
466
  windowStart: v.number(),
446
467
  eventCount: v.number(),
@@ -0,0 +1,384 @@
1
+ import { v, type Infer } from "convex/values";
2
+ import { internal } from "./_generated/api.js";
3
+ import { internalMutation, mutation, query } from "./_generated/server.js";
4
+ import {
5
+ DAY_MS,
6
+ MAX_TRACKED_LINKS_PER_SITE,
7
+ TRACKED_LINK_CLICK_SHARDS,
8
+ } from "./constants.js";
9
+ import { fail } from "./errors.js";
10
+ import { cleanString, originAllowed, sanitizeOpaqueId } from "./sanitize.js";
11
+ import {
12
+ trackedLinkChannelValidator,
13
+ trackedLinkFieldsValidator,
14
+ } from "./validators.js";
15
+
16
+ const SLUG = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/;
17
+ const PLATFORM = /^[a-z0-9_]{2,24}$/;
18
+ const MAX_DESTINATION_LENGTH = 2048;
19
+ const MAX_CAMPAIGN_LENGTH = 64;
20
+ const RECENT_DAYS = 30;
21
+ // Each shard's all-time counter sits beside the daily ones under this day, so
22
+ // a link's total is TRACKED_LINK_CLICK_SHARDS rows however long it has lived.
23
+ const ALL_TIME = 0;
24
+ const PURGE_BATCH = 500;
25
+
26
+ type Channel = Infer<typeof trackedLinkChannelValidator>;
27
+
28
+ const trackedLinkDocumentValidator = trackedLinkFieldsValidator.extend({
29
+ _id: v.id("trackedLinks"),
30
+ _creationTime: v.number(),
31
+ });
32
+
33
+ const redirectValidator = trackedLinkFieldsValidator.pick(
34
+ "slug",
35
+ "destination",
36
+ "platform",
37
+ "channel",
38
+ "campaign",
39
+ );
40
+
41
+ export function isTrackedLinkSlug(value: string): boolean {
42
+ return SLUG.test(value);
43
+ }
44
+
45
+ /**
46
+ * Where a click lands: the destination with the link's utm_* appended. A key
47
+ * the destination already carries wins, and its own query keeps its exact
48
+ * encoding because the tags are appended as text.
49
+ */
50
+ export function trackedLinkLocation(
51
+ link: Infer<typeof redirectValidator>,
52
+ ): string {
53
+ const url = new URL(link.destination);
54
+ const utm: Array<[string, string]> = [
55
+ ["utm_source", link.platform],
56
+ ["utm_medium", link.channel],
57
+ ["utm_campaign", link.campaign ?? link.slug],
58
+ ["utm_content", link.slug],
59
+ ];
60
+ const tags = new URLSearchParams(
61
+ utm.filter(([key]) => !url.searchParams.has(key)),
62
+ ).toString();
63
+ if (tags) url.search = url.search ? `${url.search}&${tags}` : `?${tags}`;
64
+ return url.href;
65
+ }
66
+
67
+ function sanitizeLinkSlug(value: string): string {
68
+ const slug = value.trim().toLowerCase();
69
+ if (!isTrackedLinkSlug(slug)) {
70
+ fail(
71
+ "INVALID_ARGUMENT",
72
+ "slug must be 3 to 32 lowercase letters, digits or hyphens, starting and ending with a letter or digit",
73
+ );
74
+ }
75
+ return slug;
76
+ }
77
+
78
+ function sanitizePlatform(value: string): string {
79
+ const platform = value.trim().toLowerCase();
80
+ if (!PLATFORM.test(platform)) {
81
+ fail(
82
+ "INVALID_ARGUMENT",
83
+ "platform must be 2 to 24 lowercase letters, digits or underscores",
84
+ );
85
+ }
86
+ return platform;
87
+ }
88
+
89
+ function sanitizeCampaign(value: string | undefined): string | undefined {
90
+ return value === undefined
91
+ ? undefined
92
+ : cleanString(value, MAX_CAMPAIGN_LENGTH) || undefined;
93
+ }
94
+
95
+ // No open redirect: the destination is an absolute http(s) URL on one of the
96
+ // site's own domains, the same rule the ingestion origin check applies.
97
+ function sanitizeDestination(value: string, domains: string[]): string {
98
+ let url: URL | undefined;
99
+ try {
100
+ if (value.length <= MAX_DESTINATION_LENGTH) {
101
+ url = new URL(cleanString(value, MAX_DESTINATION_LENGTH));
102
+ }
103
+ } catch {
104
+ url = undefined;
105
+ }
106
+ if (
107
+ !url ||
108
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
109
+ url.username ||
110
+ url.password
111
+ ) {
112
+ fail(
113
+ "INVALID_ARGUMENT",
114
+ "destination must be an absolute http(s) URL without credentials",
115
+ );
116
+ }
117
+ if (!originAllowed(url.href, domains)) {
118
+ fail(
119
+ "INVALID_ARGUMENT",
120
+ "destination must be on one of the site's domains",
121
+ );
122
+ }
123
+ return url.href;
124
+ }
125
+
126
+ export const create = mutation({
127
+ args: {
128
+ siteId: v.id("sites"),
129
+ slug: v.string(),
130
+ destination: v.string(),
131
+ platform: v.string(),
132
+ channel: trackedLinkChannelValidator,
133
+ campaign: v.optional(v.string()),
134
+ createdBy: v.string(),
135
+ },
136
+ returns: v.id("trackedLinks"),
137
+ handler: async (ctx, args) => {
138
+ const site = await ctx.db.get("sites", args.siteId);
139
+ if (!site) fail("NOT_FOUND", "site not found");
140
+ const slug = sanitizeLinkSlug(args.slug);
141
+ const destination = sanitizeDestination(args.destination, site.domains);
142
+ const platform = sanitizePlatform(args.platform);
143
+ let createdBy: string;
144
+ try {
145
+ createdBy = sanitizeOpaqueId(args.createdBy, "createdBy");
146
+ } catch (error) {
147
+ fail(
148
+ "INVALID_ARGUMENT",
149
+ error instanceof Error ? error.message : "invalid createdBy",
150
+ );
151
+ }
152
+ const taken = await ctx.db
153
+ .query("trackedLinks")
154
+ .withIndex("by_slug", (range) => range.eq("slug", slug))
155
+ .unique();
156
+ if (taken) fail("CONFLICT", "another tracked link already uses this slug");
157
+ const links = await ctx.db
158
+ .query("trackedLinks")
159
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
160
+ .take(MAX_TRACKED_LINKS_PER_SITE);
161
+ if (links.length >= MAX_TRACKED_LINKS_PER_SITE) {
162
+ fail("LIMIT_EXCEEDED", "tracked link limit reached", {
163
+ limit: MAX_TRACKED_LINKS_PER_SITE,
164
+ });
165
+ }
166
+ return await ctx.db.insert("trackedLinks", {
167
+ siteId: args.siteId,
168
+ slug,
169
+ destination,
170
+ platform,
171
+ channel: args.channel,
172
+ campaign: sanitizeCampaign(args.campaign),
173
+ createdAt: Date.now(),
174
+ createdBy,
175
+ });
176
+ },
177
+ });
178
+
179
+ /** Everything but the slug, which every link already printed carries. */
180
+ export const update = mutation({
181
+ args: {
182
+ siteId: v.id("sites"),
183
+ linkId: v.id("trackedLinks"),
184
+ destination: v.optional(v.string()),
185
+ platform: v.optional(v.string()),
186
+ channel: v.optional(trackedLinkChannelValidator),
187
+ campaign: v.optional(v.union(v.string(), v.null())),
188
+ disabled: v.optional(v.boolean()),
189
+ },
190
+ returns: v.null(),
191
+ handler: async (ctx, args) => {
192
+ const link = await ctx.db.get("trackedLinks", args.linkId);
193
+ if (!link || link.siteId !== args.siteId) {
194
+ fail("NOT_FOUND", "tracked link not found");
195
+ }
196
+ const patch: {
197
+ destination?: string;
198
+ platform?: string;
199
+ channel?: Channel;
200
+ campaign?: string;
201
+ disabledAt?: number;
202
+ } = {};
203
+ if (args.destination !== undefined) {
204
+ const site = await ctx.db.get("sites", args.siteId);
205
+ if (!site) fail("NOT_FOUND", "site not found");
206
+ patch.destination = sanitizeDestination(args.destination, site.domains);
207
+ }
208
+ if (args.platform !== undefined) {
209
+ patch.platform = sanitizePlatform(args.platform);
210
+ }
211
+ if (args.channel !== undefined) patch.channel = args.channel;
212
+ if (args.campaign !== undefined) {
213
+ patch.campaign = sanitizeCampaign(args.campaign ?? undefined);
214
+ }
215
+ if (args.disabled !== undefined) {
216
+ patch.disabledAt = args.disabled
217
+ ? (link.disabledAt ?? Date.now())
218
+ : undefined;
219
+ }
220
+ await ctx.db.patch("trackedLinks", args.linkId, patch);
221
+ return null;
222
+ },
223
+ });
224
+
225
+ export const remove = mutation({
226
+ args: { siteId: v.id("sites"), linkId: v.id("trackedLinks") },
227
+ returns: v.null(),
228
+ handler: async (ctx, args): Promise<null> => {
229
+ const link = await ctx.db.get("trackedLinks", args.linkId);
230
+ if (!link || link.siteId !== args.siteId) {
231
+ fail("NOT_FOUND", "tracked link not found");
232
+ }
233
+ await ctx.db.delete("trackedLinks", args.linkId);
234
+ await ctx.scheduler.runAfter(0, internal.trackedLinks.purgeClicks, {
235
+ linkId: args.linkId,
236
+ });
237
+ return null;
238
+ },
239
+ });
240
+
241
+ /**
242
+ * The site's links, newest first, with their clicks and bot hits: all time,
243
+ * and over the 30 UTC days ending on the day of `now`. The caller passes the
244
+ * clock so the subscription does not go stale.
245
+ */
246
+ export const list = query({
247
+ args: { siteId: v.id("sites"), now: v.number() },
248
+ returns: v.array(
249
+ trackedLinkDocumentValidator.extend({
250
+ clicks: v.number(),
251
+ bots: v.number(),
252
+ clicksLast30Days: v.number(),
253
+ botsLast30Days: v.number(),
254
+ }),
255
+ ),
256
+ handler: async (ctx, args) => {
257
+ if (!Number.isSafeInteger(args.now) || args.now < RECENT_DAYS * DAY_MS) {
258
+ fail("INVALID_ARGUMENT", "now must be a Unix time in milliseconds");
259
+ }
260
+ const since =
261
+ Math.floor(args.now / DAY_MS) * DAY_MS - (RECENT_DAYS - 1) * DAY_MS;
262
+ const links = await ctx.db
263
+ .query("trackedLinks")
264
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
265
+ .order("desc")
266
+ .take(MAX_TRACKED_LINKS_PER_SITE);
267
+ const sum = (rows: Array<{ clicks: number; bots: number }>) =>
268
+ rows.reduce(
269
+ (total, row) => ({
270
+ clicks: total.clicks + row.clicks,
271
+ bots: total.bots + row.bots,
272
+ }),
273
+ { clicks: 0, bots: 0 },
274
+ );
275
+ // Up to 4 + 120 counter rows per link, so a full site of 200 links whose
276
+ // every link fills every shard every day reads about 24,800 rows, inside
277
+ // the 32,000 documents a query may read. Raising either cap needs a
278
+ // finished day's shards folded into one row first.
279
+ return await Promise.all(
280
+ links.map(async (link) => {
281
+ const total = sum(
282
+ await ctx.db
283
+ .query("trackedLinkClicks")
284
+ .withIndex("by_linkId_and_dayStart_and_shard", (range) =>
285
+ range.eq("linkId", link._id).eq("dayStart", ALL_TIME),
286
+ )
287
+ .take(TRACKED_LINK_CLICK_SHARDS),
288
+ );
289
+ const recent = sum(
290
+ await ctx.db
291
+ .query("trackedLinkClicks")
292
+ .withIndex("by_linkId_and_dayStart_and_shard", (range) =>
293
+ range
294
+ .eq("linkId", link._id)
295
+ .gte("dayStart", since)
296
+ .lt("dayStart", since + RECENT_DAYS * DAY_MS),
297
+ )
298
+ .take(RECENT_DAYS * TRACKED_LINK_CLICK_SHARDS),
299
+ );
300
+ return {
301
+ ...link,
302
+ clicks: total.clicks,
303
+ bots: total.bots,
304
+ clicksLast30Days: recent.clicks,
305
+ botsLast30Days: recent.bots,
306
+ };
307
+ }),
308
+ );
309
+ },
310
+ });
311
+
312
+ /**
313
+ * Counts one click on a live link and returns where it goes, or null for an
314
+ * unknown or disabled slug. Bots are counted apart and never as clicks. The
315
+ * destination is checked against the site's domains again, so a domain the
316
+ * site has since dropped stops redirecting.
317
+ */
318
+ export const recordClick = internalMutation({
319
+ args: { slug: v.string(), bot: v.boolean() },
320
+ returns: v.union(redirectValidator, v.null()),
321
+ handler: async (ctx, args) => {
322
+ const link = await ctx.db
323
+ .query("trackedLinks")
324
+ .withIndex("by_slug", (range) => range.eq("slug", args.slug))
325
+ .unique();
326
+ if (!link || link.disabledAt !== undefined) return null;
327
+ const site = await ctx.db.get("sites", link.siteId);
328
+ if (!site || !originAllowed(link.destination, site.domains)) return null;
329
+ const now = Date.now();
330
+ const shard = Math.floor(Math.random() * TRACKED_LINK_CLICK_SHARDS);
331
+ for (const dayStart of [Math.floor(now / DAY_MS) * DAY_MS, ALL_TIME]) {
332
+ const row = await ctx.db
333
+ .query("trackedLinkClicks")
334
+ .withIndex("by_linkId_and_dayStart_and_shard", (range) =>
335
+ range
336
+ .eq("linkId", link._id)
337
+ .eq("dayStart", dayStart)
338
+ .eq("shard", shard),
339
+ )
340
+ .unique();
341
+ if (row) {
342
+ await ctx.db.patch(
343
+ "trackedLinkClicks",
344
+ row._id,
345
+ args.bot ? { bots: row.bots + 1 } : { clicks: row.clicks + 1 },
346
+ );
347
+ } else {
348
+ await ctx.db.insert("trackedLinkClicks", {
349
+ linkId: link._id,
350
+ dayStart,
351
+ shard,
352
+ clicks: args.bot ? 0 : 1,
353
+ bots: args.bot ? 1 : 0,
354
+ });
355
+ }
356
+ }
357
+ return {
358
+ slug: link.slug,
359
+ destination: link.destination,
360
+ platform: link.platform,
361
+ channel: link.channel,
362
+ ...(link.campaign === undefined ? {} : { campaign: link.campaign }),
363
+ };
364
+ },
365
+ });
366
+
367
+ /** Deletes a removed link's counters a batch at a time. */
368
+ export const purgeClicks = internalMutation({
369
+ args: { linkId: v.id("trackedLinks") },
370
+ returns: v.null(),
371
+ handler: async (ctx, args): Promise<null> => {
372
+ const rows = await ctx.db
373
+ .query("trackedLinkClicks")
374
+ .withIndex("by_linkId_and_dayStart_and_shard", (range) =>
375
+ range.eq("linkId", args.linkId),
376
+ )
377
+ .take(PURGE_BATCH);
378
+ for (const row of rows) await ctx.db.delete("trackedLinkClicks", row._id);
379
+ if (rows.length === PURGE_BATCH) {
380
+ await ctx.scheduler.runAfter(0, internal.trackedLinks.purgeClicks, args);
381
+ }
382
+ return null;
383
+ },
384
+ });
@@ -1,3 +1,5 @@
1
+ import { PLATFORMS } from "./platforms.js";
2
+
1
3
  export type ClientClassification = {
2
4
  browser: string;
3
5
  os: string;
@@ -100,6 +102,15 @@ export function classifyUserAgent(userAgent: string): ClientClassification {
100
102
  };
101
103
  }
102
104
 
105
+ /**
106
+ * The platform whose in-app browser sent the request (Instagram, Facebook,
107
+ * TikTok…), from the marker each app appends to the User-Agent. The system
108
+ * browser a messaging app hands a link to carries none.
109
+ */
110
+ export function inAppPlatform(userAgent: string): string | undefined {
111
+ return PLATFORMS.find((row) => row.agent?.test(userAgent))?.id;
112
+ }
113
+
103
114
  function matchAgent(
104
115
  table: readonly (readonly [RegExp, string])[],
105
116
  userAgent: string,
@@ -50,6 +50,9 @@ export const trackerEventValidator = v.object({
50
50
  // Web Vitals measurement: milliseconds, or CLS scaled by 1000. Only "vital"
51
51
  // events carry it; sanitization strips it from every other type.
52
52
  value: v.optional(v.number()),
53
+ // The names of the click-ID keys on the landing URL (`fbclid`, `gclid`…),
54
+ // never their values; sanitization keeps only the known ones (origin.ts).
55
+ clid: v.optional(v.array(v.string())),
53
56
  });
54
57
 
55
58
  export const ingestContextValidator = v.object({
@@ -61,8 +64,41 @@ export const ingestContextValidator = v.object({
61
64
  os: v.optional(v.string()),
62
65
  device: v.optional(v.string()),
63
66
  visitorKey: v.optional(v.string()),
67
+ // The platform whose in-app browser sent the batch (useragent.ts), derived
68
+ // by the HTTP action so the raw User-Agent never reaches a mutation.
69
+ inApp: v.optional(v.string()),
64
70
  });
65
71
 
72
+ // How a session arrived (channel) and which rule proved it (evidence); see
73
+ // origin.ts. "legacy" is never stored: reports mark a session recorded before
74
+ // origins existed with it when they classify that session on read.
75
+ export const channelValidator = v.union(
76
+ v.literal("direct"),
77
+ v.literal("organic_search"),
78
+ v.literal("paid_search"),
79
+ v.literal("organic_social"),
80
+ v.literal("paid_social"),
81
+ v.literal("messaging"),
82
+ v.literal("email"),
83
+ v.literal("affiliate"),
84
+ v.literal("ai"),
85
+ v.literal("display"),
86
+ v.literal("referral"),
87
+ // A link printed off the web: a QR code, a flyer, a receipt. Only a tracked
88
+ // link can say so, since the scan itself carries no referrer.
89
+ v.literal("offline"),
90
+ );
91
+
92
+ export const evidenceValidator = v.union(
93
+ v.literal("utm"),
94
+ v.literal("clickId"),
95
+ v.literal("referrer"),
96
+ v.literal("inApp"),
97
+ v.literal("affiliate"),
98
+ v.literal("none"),
99
+ v.literal("legacy"),
100
+ );
101
+
66
102
  export const batchedEventValidator = trackerEventValidator.extend({
67
103
  ...ingestContextValidator.fields,
68
104
  aggregateCountry: v.optional(v.string()),
@@ -94,6 +130,10 @@ export const sessionFieldsValidator = v.object({
94
130
  exitPath: v.string(),
95
131
  referrer: v.optional(v.string()),
96
132
  source: v.string(),
133
+ // The first touch's origin (origin.ts); absent on sessions recorded before.
134
+ platform: v.optional(v.string()),
135
+ channel: v.optional(channelValidator),
136
+ evidence: v.optional(evidenceValidator),
97
137
  utmSource: v.optional(v.string()),
98
138
  utmMedium: v.optional(v.string()),
99
139
  utmCampaign: v.optional(v.string()),
@@ -155,6 +195,10 @@ export const dimensionTypeValidator = v.union(
155
195
  // host of an outbound click (never its path).
156
196
  v.literal("medium"),
157
197
  v.literal("outbound"),
198
+ // The session's origin platform, channel and evidence (origin.ts).
199
+ v.literal("platform"),
200
+ v.literal("channel"),
201
+ v.literal("evidence"),
158
202
  );
159
203
 
160
204
  export const dimensionSlotValidator = v.object({
@@ -254,6 +298,31 @@ export const affiliateFieldsValidator = v.object({
254
298
  updatedAt: v.number(),
255
299
  });
256
300
 
301
+ // Where a tracked link was published: chosen at creation and carried to the
302
+ // landing page as its utm_medium.
303
+ export const trackedLinkChannelValidator = v.union(
304
+ v.literal("organic_social"),
305
+ v.literal("paid_social"),
306
+ v.literal("messaging"),
307
+ v.literal("email"),
308
+ v.literal("affiliate"),
309
+ v.literal("display"),
310
+ v.literal("referral"),
311
+ v.literal("offline"),
312
+ );
313
+
314
+ export const trackedLinkFieldsValidator = v.object({
315
+ siteId: v.id("sites"),
316
+ slug: v.string(),
317
+ destination: v.string(),
318
+ platform: v.string(),
319
+ channel: trackedLinkChannelValidator,
320
+ campaign: v.optional(v.string()),
321
+ createdAt: v.number(),
322
+ createdBy: v.string(),
323
+ disabledAt: v.optional(v.number()),
324
+ });
325
+
257
326
  export const ingestResultValidator = v.object({
258
327
  accepted: v.number(),
259
328
  duplicates: v.number(),
@@ -278,3 +347,5 @@ export type IngestContext = Infer<typeof ingestContextValidator>;
278
347
  export type EventProperties = Infer<typeof eventPropertiesValidator>;
279
348
  export type FunnelStep = Infer<typeof funnelStepValidator>;
280
349
  export type EventType = Infer<typeof eventTypeValidator>;
350
+ export type Channel = Infer<typeof channelValidator>;
351
+ export type Evidence = Infer<typeof evidenceValidator>;
@@ -1,12 +1,12 @@
1
1
  // Generated by scripts/build-tracker.mjs. Do not edit.
2
- export const RASTRO_VERSION = "0.3.0";
3
- export const TRACKER_SOURCE = "(()=>{const e=document.currentScript,t=e?.dataset.site,s=e?.dataset.endpoint||e?.src&&new URL(\"events\",e.src).href;if(!t||!s)return;const o=()=>crypto.randomUUID?.()||Math.random()+\"\",n=new URLSearchParams(location.search),r=n.get(\"ref\")?.slice(0,64);let a,i,c;for(const e of[\"source\",\"medium\",\"campaign\",\"term\",\"content\"]){const t=n.get(\"utm_\"+e)?.slice(0,64);t&&((a??={})[\"utm_\"+e]=t)}try{i=sessionStorage._r||o(),sessionStorage._r=i,c=r||sessionStorage._a,c&&(sessionStorage._a=c),a||=sessionStorage._u&&JSON.parse(sessionStorage._u),a&&(sessionStorage._u=JSON.stringify(a))}catch{i=o(),c=r}let d=e.dataset.visitor;try{d||=localStorage._rv||=o()}catch{}d||=i;let l,p=0;const g=[],h=document.referrer?new URL(document.referrer).origin:void 0,u=e=>{const o=g.splice(0,50);if(!o.length)return;const n=()=>new Blob([JSON.stringify({siteId:t,events:o,sentAt:Date.now()})]);let r=n();for(;r.size>64e3&&o.length>1;)g.unshift(o.pop()),r=n();e&&navigator.sendBeacon(s,r)||fetch(s,{method:\"POST\",body:r,keepalive:1}).catch(()=>{}),g.length&&u(e)},m=(e,t,s)=>{const o=Date.now();g.push({eventId:`${i}.${o.toString(36)}.${p}`,sessionId:i,visitorId:d,type:e,name:t,path:location.pathname.slice(0,256),referrer:h,timestamp:o,sequence:p++,affiliateSlug:c,...s}),clearTimeout(l),l=setTimeout(u,1200)};let v;const f=()=>{const e=location.pathname;e!==v&&(v=e,m(\"pageview\",void 0,a&&{properties:a}))};window.rastro=(e,t,s)=>{if(\"context\"===e)return{sessionId:i,visitorId:d};\"event\"===e&&m(\"custom\",`${t||\"event\"}`.slice(0,80),s&&JSON.stringify(s).length>2e4?void 0:{properties:s})},document.addEventListener(\"click\",e=>{const t=e.target?.closest(\"a,button,[data-rastro-event]\");if(!t)return;const s=t.dataset.rastroEvent,o=t.href,n=o&&new URL(o),r=/^https?:$/.test(n?.protocol),a=r&&n.origin!==location.origin;m(s?\"custom\":a?\"outbound\":\"click\",s,{target:(t.dataset.rastroLabel||t.innerText||t.tagName).slice(0,64),href:r?((a?n.origin:\"\")+n.pathname).slice(0,256):void 0})},1);const S=e=>{const t=history[e];history[e]=(...e)=>{t.apply(history,e),f()}};S(\"pushState\"),S(\"replaceState\"),addEventListener(\"popstate\",f),addEventListener(\"pagehide\",()=>{m(\"leave\"),u(1)}),addEventListener(\"pageshow\",e=>{e.persisted&&(m(\"heartbeat\"),u(1))}),setInterval(()=>{m(\"heartbeat\"),u()},2e4),f()})();";
4
- export const TRACKER_GZIP_BASE64 = "H4sIAAAAAAACA3VV227cNhD9FZswBBKeMLbjGIUEZtEgfXDhJkE3eTLcmKZGEhGJVMnROtuV/r2QVuv1JXmTZsi5nDlnyLlQ7zbGu0gHqHJvugYdSdOFgI6WJtiWgBQuZK5JRyQZLSHExxZ0eeuto77HhYzBJInD+4Ovf19xhit0FBngaBeyClhktuCH1PeHUQSkLrhsm92rsRQT1i15GbTLffP16+WHheSi7//SVM1GLo4ZA6fmHEvUwVSfddBN5LU3mqx3Mk5WAUE5WSJxFrBgYiFjbQ3yE7g4F1mNdKDBgskKH/gMwYEvrln0XTDIgDWY265hwIxuWm1Lx4ARhsniHaEjdiNm9GiXqqPmGzvGZ9koSTjXi4XaDOJ6d+ZGkRgorDdWRYzRerckH3SJ8lvoe88FvDArC0aFvn/u0GCShL+wKiNA9/2L8F2S/Ln89FG2OkR8ca0ToH8SrVPTnUjButIWa66FGIwmU22sGqs1KgwjrLnCB3asbLTkQza2mfe9GmdU7/tZ9f14dQ4zjCfsNJoaWnUyc6NU1zdQ7ekZsMAQMCx2RHvhEdIHW1qXrrzND06gU/jAc69KGdt5Nm9PxMRIL2t0JVVPSekmUo5Z3tf+jl8/638ziuEyTwm2RE89RHT0O6UfNKF0/p6LQdxsuRaU42LiWhZktP/hu4tzfJMku8zvTjNRys7FyhbEvWx9y8VEYS4yTBKnV7bU5IOM6PL3qI13PEIQfV8gmYpH2DRIlc9T9vnT8guDO5+v0wDfEVtd2xWmp4OQE9B8kv0goJyTJ0nHUQzQKI5AEMUjtPa9ZKVsu1jxzdTuZZ7eHm3sII82XpJfTrDwNxditLTD7Y68l3lqYabBZZ7mQOsWUwSnG0wJWk1V+qDc8W90PIjn7O2FgN1Y0wrINhhJN+0E9r8dOoNpe3wMuihsbTXhsu7K1ICUMg4CTI06fLEN+o54LaBWEWn338Hp2cmJGKYBreahF+rJUnxRWoaHSq2ShK8UQsNZq0tcWbxnMJNNJ8mmDb7FQBZjqgchhuzeutzfy6AjBf8IZVvw7TL5QUwphTMBN78Ab8i2S3U6myQNZ6aL5BsGt0cb6vvZO9w+APjbiYA4y31P3Sh2tDvD88W28PRx1XEQAzzoSuf5H2PgKxsJHQbOTG3NdwZ7WZFCSTqUSAtpah8xEmca7joi7+B63Aevtt2/mmq8YVvl0VPJRUUPu2N7fEoMXtH0foBTfv/C+FEgr/+piNq4SI9eSxrTuoVsgydvfC1Aq5Akbt4Hh2o/z60la3hc7EBM9YL5ju5853KW7nqMsNk2lvLntV3pO6z7nqR1DsMX/EHjD+nyo25QPH4BYCw+DYvxFdjlThkTx3tiiSekn1fXOIZTMYOzVI/xrmwkH9bXeJPtPxWXUuLILJK6bes1n32AAgouhiFbcjbKeEmakAlYjs9jW2uDO8vLabe+jZMTip+6dYmVzZHBJJ2Gsxr1agzV8VMx/OpKrPz9lkIoWwxxdOZJwhvOKtSB7lDTHGMMEpEuHWFY6Zrv8jw9JwY4w/Ntn4KL7H/zTKaM3wgAAA==";
5
- export const TRACKER_RAW_BYTES = 2271;
6
- export const TRACKER_GZIP_BYTES = 1177;
7
- export const TRACKER_HASH = "14dc90ee3a9b5ce4";
8
- export const VITALS_SOURCE = "(()=>{const t=document.currentScript,e=t?.dataset.site,n=t?.dataset.endpoint||t?.src&&new URL(\"events\",t.src).href;if(!e||!n)return;const r=()=>crypto.randomUUID?.()||Math.random()+\"\";let a;try{a=sessionStorage._r||r(),sessionStorage._r=a}catch{a=r()}const s=t.dataset.visitor||a,o=location.pathname.slice(0,256),i={},c=[],d=(t,e,n)=>{try{const r=new PerformanceObserver(t=>e(t.getEntries()));r.observe({type:t,buffered:1,...n}),c.push({observer:r,handler:e})}catch{}},h=performance.getEntriesByType?.(\"navigation\")[0];h&&h.responseStart>0&&(i.TTFB=h.responseStart),d(\"paint\",t=>{for(const e of t)\"first-contentful-paint\"===e.name&&(i.FCP=e.startTime)}),d(\"largest-contentful-paint\",t=>{const e=t[t.length-1];e&&(i.LCP=e.startTime)});let l=0,f=0,m=0;d(\"layout-shift\",t=>{for(const e of t)e.hadRecentInput||(l&&e.startTime-m<1e3&&e.startTime-f<5e3?l+=e.value:(l=e.value,f=e.startTime),m=e.startTime,i.CLS=Math.max(i.CLS||0,1e3*l))});const u=[],p=new Set,v=t=>{for(const e of t)e.interactionId&&(p.add(e.interactionId),u.push(e.duration));if(!u.length)return;u.sort((t,e)=>e-t),u.length=Math.min(u.length,10);const e=performance.interactionCount||p.size;i.INP=u[Math.min(u.length-1,Math.floor(e/50))]};d(\"event\",v,{durationThreshold:40}),d(\"first-input\",v);let g=0;const y=()=>{if(g)return;for(const{observer:t,handler:e}of c)e(t.takeRecords()),t.disconnect();const t=Date.now(),r=Object.keys(i).map((e,n)=>({eventId:`${a}.${t.toString(36)}.v${n}`,sessionId:a,visitorId:s,type:\"vital\",name:e,value:Math.round(i[e]),path:o,timestamp:t,sequence:n}));if(!r.length)return;g=1;const d=new Blob([JSON.stringify({siteId:e,events:r,sentAt:t})]),h=navigator.sendBeacon?.bind(navigator);h&&h(n,d)||fetch(n,{method:\"POST\",body:d,keepalive:1}).catch(()=>{})};document.addEventListener(\"visibilitychange\",()=>{\"hidden\"===document.visibilityState&&y()}),addEventListener(\"pagehide\",y)})();";
9
- export const VITALS_GZIP_BASE64 = "H4sIAAAAAAACA3VV72/bNhD9V1IhMMiV5uxl6Qe5TLCkLeAha4I4+RQEK02eLC4SqZEnd5qk/32gZOfn+k06kXfv3b17IoSKk1Y5G/AAhXaqLsEiV7X3YHGlvKmQgcBTriXKAMiDQWD2eQSsrpyx2HV4yoNXk4mF7we31xckgS1YDAnDGKc895AtTEbeQde9s9QD1t4uxupeRCjKNxU67qXVrry9XX465YR23R8S812Q0PdJsigAD+QCfdNKESAE4+wKnZcb4H/6rvOEsjdhIXslUeWtFJ7QfiwbBD4S2Zpg0Pmuk8yJwimJxlleScytLIGHwiggM/bL8QfKjGh7psTdPdOCIANmYyMjoD2d2IQr8JnzpbQKLtcB/BY8QXECBPkG8LNFbyAQSunCczceIC02FaTI1nWWgQedzhnn3PaUKV7VISft7qRPPcul1QX4FHq6I9f3LBfVU91nhc6am6aCU04SK7dmM9BL6N3sfpFPJjn3ECpnA6xQejyZTSbE8JubL2fi1SfKNEkqaSwmDMVJmzlPRtJw4LIDpElmfMCpchbBYlYX0/G0EAJ4bOWQ+sv5lQAeYsYbUwLth7yF9Bv4v8tDqV0ZgXfIC7AbzKfz+8WY7+JNvkElhZixTMxYKWaLIX/japyG3GQ/gg88l/oaFFhc2qrGriPFZPIs9bT8OIejl6Hs4zEcnRbvBfCtLGpISbF/ZNkLXKx8/soMP79YiUHgpfyHDK9dN2NzOPqpoJHFCK6OWqsGVa0A2Vb8ALuxCF6qONulnkxIxaXW5FWcsnrUEnBd+0EIlA6bWe/6ul/OmgfnkUSJU3ECU4xXxyM70MaSfYTNZ3u48EKDz4qfuzpaRcWD+RcWhi+/Xon67k2q6ZwNsaxwzhP4+XhG6X0fJzh4SsK2rN1Dv8k9hNwVOv11NqpoFKCJ00vYdhTCRsx22JrBalqTkc2e5mMjn5YLny2Xyw4UjVuL8gGuQTmv49oy5NoE5awFhWRPHcUnicCt+04o8+Jy/Rco5A/QBGIoL2VFyGgXpB24LHX67bCVPT9skaNboTd2Q44+0J5vD1vbf9tb2VKnku1MaqnTwAanSLYGZZGwuFgpsFF9o2G62mpi7uCesuhiqWNoSggoyypFFuDvGqyC1Pa74ftXw9+I+Y6THoR3Vrg1uft9dfmVhwGlyRrSxj/CUqfARrdPPQtg8TdMsaf3lOVi5zbO8wBWn4FUzp7ytbGaPH6igwcRyzTtugxQxee2BMydTpOry9VNwtZON6lmDwCVLMwW0nlP+eB6ZBhoT/vF4y9Mav054rkwAcGCJ0ns3NoUBhuVS7uBhA23ktxoDTa60+Plp6MrlAiTSUOiP73NWckN5EZDwhraU0IX/wGQzzziUQcAAA==";
10
- export const VITALS_RAW_BYTES = 1873;
11
- export const VITALS_GZIP_BYTES = 1042;
12
- export const VITALS_HASH = "ec87741c5dfdd0cb";
2
+ export const RASTRO_VERSION = "0.5.0";
3
+ export const TRACKER_SOURCE = "(()=>{const t=document.currentScript,e=t?.dataset.site,i=t?.dataset.endpoint||t?.src&&new URL(\"events\",t.src).href;if(!e||!i)return;const n=()=>crypto.randomUUID?.()||Math.random()+\"\",s=new URLSearchParams(location.search),r=s.get(\"ref\")?.slice(0,64);let o;for(const t of[\"source\",\"medium\",\"campaign\",\"term\",\"content\"]){const e=s.get(\"utm_\"+t)?.slice(0,64);e&&((o??={})[\"utm_\"+t]=e)}const a=\"gclid gbraid wbraid msclkid ttclid twclid li_fat_id ScCid rdt_cid dclid epik srsltid mc_cid mc_eid _hsenc igsh igshid fbclid\".split(\" \").filter(t=>s.has(t)).slice(0,8);let c,d,l,h,p=t.dataset.visitor||\"\";try{p||=localStorage._rv||=n()}catch{}try{d=sessionStorage,l=d._r,c=d._ri==p&&d._rt,h=r||d._a,h&&(d._a=h),o||=d._u&&JSON.parse(d._u),o&&(d._u=JSON.stringify(o))}catch{h=r}let g,v=0;const m=[],u=document.referrer.split(\"/\",3).join(\"/\")||void 0,f=t=>{const n=m.splice(0,50);if(!n.length)return;const s=()=>new Blob([JSON.stringify({siteId:e,events:n,sentAt:Date.now()})]);let r=s();for(;r.size>64e3&&n.length>1;)m.unshift(n.pop()),r=s();t&&navigator.sendBeacon(i,r)||fetch(i,{method:\"POST\",body:r,keepalive:1}).catch(()=>{}),m.length&&f(t)},_=(t,e,i)=>{const s=Date.now(),r=s-c;c=s,r<18e5||(l=n(),\"pageview\"!=t&&(y=\"\",S()));try{d._r=l,d._ri=p,d._rt=s}catch{}m.push({eventId:`${l}.${s.toString(36)}.${v}`,sessionId:l,visitorId:p||l,type:t,name:e,path:location.pathname.slice(0,256),referrer:u,timestamp:s,sequence:v++,affiliateSlug:h,...i}),clearTimeout(g),g=setTimeout(f,1200)};let y;const S=()=>{const t=location.pathname;t!=y&&(y=t,_(\"pageview\",void 0,{properties:o,clid:a.length?a:void 0}))};window.rastro=(t,e,i)=>{if(\"context\"==t)return{sessionId:l,visitorId:p||l};\"event\"==t&&_(\"custom\",`${e||\"event\"}`.slice(0,80),i&&JSON.stringify(i).length>2e4?void 0:{properties:i})},document.addEventListener(\"click\",t=>{const e=t.target?.closest(\"a,button,[data-rastro-event]\");if(!e)return;const i=e.dataset.rastroEvent,n=e.href,s=n&&new URL(n),r=/^https?:$/.test(s?.protocol),o=r&&s.origin!=location.origin;_(i?\"custom\":o?\"outbound\":\"click\",i,{target:(e.dataset.rastroLabel||e.innerText||e.tagName).slice(0,64),href:r?((o?s.origin:\"\")+s.pathname).slice(0,256):void 0})},1);for(const t of[\"pushState\",\"replaceState\"]){const e=history[t];history[t]=(...t)=>{e.apply(history,t),S()}}addEventListener(\"popstate\",S);let w=0;const b=()=>{_(\"heartbeat\"),f()},I=()=>{w&&(clearInterval(w),w=0,_(\"leave\"),f(1))},L=()=>{document.hidden?I():w||(b(),w=setInterval(b,2e4))};addEventListener(\"pagehide\",I),addEventListener(\"pageshow\",L),document.addEventListener(\"visibilitychange\",L),w=setInterval(b,2e4),S(),L()})();";
4
+ export const TRACKER_GZIP_BASE64 = "H4sIAAAAAAACA31VWY/bNhD+K14iEEjshNnNhUIuY/R6cLE9UCdPi61DSyOJjUSq5MiOa+m/F5R8bI72RSRnhpqD3zfDuVBvDpmzgWakcpd1DVqSWec9Wlpl3rQEqGghc006IMlgCME8lqDNW2cs9T0tZPBZkljczd79cccZbtFSYEBRLmTlsZibgl9h318Z4ZE6b+eTd6tiKJnft+Sk1zZ3zbt3yx8Xkou+/0VTdRRycc0YBHX0sULts+p37XUTeO0yTcZZGUapAK+CLJE481gwsZChNhnyG3j9UsxrpJmbF87zY/ozV9yz4DqfIQPWYG66hgHLdNNqU1oGjNCPEmcJLbEHcawcntx01KzZNX3mCZOEc7dYqMMg7k82DwrFMF3XipVZbfJZufHa5LPdtDQhqz+YfEY0Kmk3LrVZF5rWJp+tsh9MPvM5rTOTz/JRi635MAs+1BR/kI2aJlujyWfrKqDNZqYM1fgx+azYxEtMhrY2xNmMCVmYmtBzUm+CrHTgJMQ5l2+momWQQw0VtIrOGNiaYMj5vmdsTn5/aPtexdeoV+S8LlGu/bbvleViyDRl1WGIVrkKGIJx9mgFtcrl2kM2LkapNknijqBSvu9zudZQJQmPG1UJcH0fLbsk+Xn126+y1T5gVHYC3GTWqVETyBtbmmLPnThFUCk/xHRK2KqbIwgbdf8A3YUHHgv0Hv2pQs8YvBDyL2ds3Iu+3zqTz26gUHSmkVXNaD6W7NWNGAFvZY22pOpTzIcR8xHJ39duw+8/i/UQubbMU4SJR6mFgJa+o/RHTSit23ExiIfpVbwKXIxwnnsZzD/45vVLfJEkJ89vbueikZ0NlSmIW9m6louRIVzMKUms3ppSk/MyoM2/R505yw140fcFUlZxA4cGqXJ5yn7/bfWWwcbl+9TDB8RW12aL6e0g5FhbPnaVQUBzdJ4kBScxwFpxAgRzaTpBXXKJwTzN5pkK4L+9/QZf9T2vI2aAtbrErcEdu1KUJHyvGIMVF0KMaIsYUTVMoGnHlVQ4Ia2RbRcqfhiLuMzT908O9SCfHIIktxqLzV+8FlGyHd7DEZHLPK3hiOplnrZ9XwPtW0wJrG4wRWg1Vem548RTVJzJ8vzVawEn/KQdkGkwkG7aNEDAvzu0Gabb62vQRWFqowlXdVemFUgpzSAgq1H7t6ZB1xEvBZQqIJ3OBdw+v7kRw/jy+yOaVuqTZv5FaHO6UvuxeARrfikpHEF8aL1r0ZPBkDqInSHVx/db6HQyGoQY5jtjc7eTXgfy7tGLmoJPvfEjMaXoiPXDf1d0mE8TIlonyZqzrAvkGgbvnxyw74/K4f2lA90IMEe2X3hixAnjz/HlYgo0fZyNGcQAZ1LrPP8p/vjOBEKLnrOsNtkHBhcOoyJJ2pdIC5nVLmAgzjRsOiJn4T52vadT+k/HGB/YRHP8lN9G4blDTuajY7AKx1kYx9hlWtpIgGd/VkRtWKRPnkmKbsNCtt6Ry1wtwCmfJEE6b0pjry5PPAnma24WpxqmbsFcRxvX2ZylpxQNHKa8Uv55aHd6g3XfozTWon+LHykeSJe/6gbF44EGMfbUL+JQOwWTMiauwxlr4hMenMEzwK34YuRGdq5IU5y6HttaZzgdH83XygRyfn9PD/PLVnEpJUXkodRtW+/5UQckYncYhi+funVtmHytpr65Ozf/zcSfNWcVak8b1MQEFFwMsJxUuyThIy+XltBvdc13AnbqJtKpRr3F0f5WiAHuphtn0FUmz9Eullyku77nGx4vBqTznzbwHF9Gdn0lZl1iZXJksBTwdXWo3I7Bnfg/lEfubUxtaJ9V2pY42n8tiFg7uIuzhYv5v9gOoJoiCgAA";
5
+ export const TRACKER_RAW_BYTES = 2594;
6
+ export const TRACKER_GZIP_BYTES = 1395;
7
+ export const TRACKER_HASH = "0d369c5fe66dc9cd";
8
+ export const VITALS_SOURCE = "(()=>{const t=document.currentScript,e=t?.dataset.site,n=t?.dataset.endpoint||t?.src&&new URL(\"events\",t.src).href;if(!e||!n)return;const r=()=>crypto.randomUUID?.()||Math.random()+\"\";let s=t.dataset.visitor||\"\";try{s||=localStorage._rv||=r()}catch{}const o=Date.now();let a;try{a=sessionStorage._ri===s&&o-sessionStorage._rt<18e5&&sessionStorage._r||r(),sessionStorage._r=a,sessionStorage._ri=s,sessionStorage._rt=o}catch{a=r()}const i=s||a,c=location.pathname.slice(0,256),d={},h=[],l=(t,e,n)=>{try{const r=new PerformanceObserver(t=>e(t.getEntries()));r.observe({type:t,buffered:1,...n}),h.push({observer:r,handler:e})}catch{}},f=performance.getEntriesByType?.(\"navigation\")[0];f&&f.responseStart>0&&(d.TTFB=f.responseStart),l(\"paint\",t=>{for(const e of t)\"first-contentful-paint\"===e.name&&(d.FCP=e.startTime)}),l(\"largest-contentful-paint\",t=>{const e=t[t.length-1];e&&(d.LCP=e.startTime)});let m=0,u=0,v=0;l(\"layout-shift\",t=>{for(const e of t)e.hadRecentInput||(m&&e.startTime-v<1e3&&e.startTime-u<5e3?m+=e.value:(m=e.value,u=e.startTime),v=e.startTime,d.CLS=Math.max(d.CLS||0,1e3*m))});const g=[],p=new Set,S=t=>{for(const e of t)e.interactionId&&(p.add(e.interactionId),g.push(e.duration));if(!g.length)return;g.sort((t,e)=>e-t),g.length=Math.min(g.length,10);const e=performance.interactionCount||p.size;d.INP=g[Math.min(g.length-1,Math.floor(e/50))]};l(\"event\",S,{durationThreshold:40}),l(\"first-input\",S);let y=0;const b=()=>{if(y)return;for(const{observer:t,handler:e}of h)e(t.takeRecords()),t.disconnect();const t=Date.now(),r=Object.keys(d).map((e,n)=>({eventId:`${a}.${t.toString(36)}.v${n}`,sessionId:a,visitorId:i,type:\"vital\",name:e,value:Math.round(d[e]),path:c,timestamp:t,sequence:n}));if(!r.length)return;y=1;const s=new Blob([JSON.stringify({siteId:e,events:r,sentAt:t})]),o=navigator.sendBeacon?.bind(navigator);o&&o(n,s)||fetch(n,{method:\"POST\",body:s,keepalive:1}).catch(()=>{})};document.addEventListener(\"visibilitychange\",()=>{\"hidden\"===document.visibilityState&&b()}),addEventListener(\"pagehide\",b)})();";
9
+ export const VITALS_GZIP_BASE64 = "H4sIAAAAAAACA3VVbW/bNhD+K6lQCORKc/ayFINcJlj6AnjImqBOPwXBSosniatEauTJnSbpvw+i7MSJ22/SkXf33N1zDwmh4rxLrfF4gkLZtKnAIE8b58DgOnW6RgYCL7iSKD0g9xqBmUMLGFVbbbDv8YJ7l8axgW8nnz9dkQi2YNBHDEc75YWDbKkz8gL6/oWhDrBxZjlld2KEkrq2RsudNMpWnz+v3l1wQvv+T4nFzkjoqyhaloAnXuADhq32Gq3r+yhaoms73/eitKks12idzIH/5bZ9LxyhQyoxLbphymrFO4nAjf1GaAgqg7sUHrzX1jy6ayGEj2M7OzrBN4vf4CyOjw763hHKjsxCsu9E98dGFHaHVk7IA2QtfN9Llob6UFvDa4mFkRVwX+oUyJz9cvaaMiW6gRXi7p6VgiADZsZRj9XtGz6O6QZcZl0lTQrXGw9uC46gOAeCPAd8b9Bp8IRSunTcThdIh20NCbJNk2XgQCULxjk3A2UFrxtfkG530yWOFdKoElwCw0PrB5aJ+jHvQaLL9rat4YKTyMitzkN5Eb2b3y+zOM64A19b42GN0uH5PI6J4re3Hy7FsyPKShLVUhuMGIrzLrOOTEXDic1OkEaZdh5nqTUIBrOmnE23hRDAx1aG0B/e3gjgfox4qyugQ4hbSpfD95xDql0agXfISzA5FrPF/XKKd3UUL1CuEnPWiDnbivkyxG9tgzNf6OxH8IEXUn2CFAyuTN1g35Mqjg9Cz7ZvFnD61NS8OYPTi+qVAL6VZQMJqfafrHmCi20Pf5nib6/WIqxgJf8l4bfv52wBpz9VdKxiApePXKsDq9aAbC1+gF0bBCfTcbYrFcek5lIp8sxOWT5xCbhqXCACpUE78l1f9/KRc28dkpHiVJzDDEfX6coOtDZkb2GL+R4uPOHgQfK3thnFrOZe/wdLxVcfb0R+dxRqtmDBlpXWOgI/n80pvR/GCQbVi9iadXvot4UDX9hSJb/OJxZNBNTj9CK2nojQivkO2yaIYacz0u7LfGjk43LhwXLZ7KSg49ai/AqfILVOjWvLkCvtU2sMpEj2peOB6jEnrjd/Q4r8K7SeKMorWRMyyQXpQi0rlXx52cmBv+yQo12j0yYnp6/pwLcvOzN82WvXSiWS7bR4pRLNglJEW42yjNi4WAmwiX2TpNvGKKLu4J6yUcWSlKGuwKOs6gSZh38aMCkkZtgN3z0bfisWu5p8IN5laTfk7o/19UfuA0qdtaQb36yVSoBN71HimAeDv2OCA72nzIqd2ljHPRh1CTK15oJvtFHk4YgubRxbYpinfZ8BpgUxrKsAC6uS6OZ6fRuxjVVt4tlXgFqWegvJYqA8qB4JAx3osHx4ZKVS70c8V9ojGHAkGju30aXGNi2kySFiwSsqtFJgRnV6cH68ukaJEMcbMurTccxa5lBoBRHb0IESuvwfweV+3/MHAAA=";
10
+ export const VITALS_RAW_BYTES = 2035;
11
+ export const VITALS_GZIP_BYTES = 1097;
12
+ export const VITALS_HASH = "3dd7a68d5212c1f8";