@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.
- package/README.md +199 -50
- package/agent/integration.md +26 -10
- package/agent/manifest.json +10 -5
- package/agent/manifest.schema.json +14 -5
- package/dist/client/federation.d.ts +28 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +12 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +262 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +119 -2
- 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 +72 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +1 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/constants.d.ts +9 -2
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +31 -3
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/coverage.d.ts +1 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +6 -1
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/geo.d.ts +1 -0
- package/dist/component/geo.d.ts.map +1 -1
- package/dist/component/geo.js +1 -1
- package/dist/component/geo.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +65 -2
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +12 -0
- package/dist/component/identity.d.ts.map +1 -1
- package/dist/component/identity.js +30 -2
- package/dist/component/identity.js.map +1 -1
- package/dist/component/ingest.d.ts +36 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +111 -20
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +34 -4
- package/dist/component/live.js.map +1 -1
- package/dist/component/origin.d.ts +70 -0
- package/dist/component/origin.d.ts.map +1 -0
- package/dist/component/origin.js +230 -0
- package/dist/component/origin.js.map +1 -0
- package/dist/component/platforms.d.ts +33 -0
- package/dist/component/platforms.d.ts.map +1 -0
- package/dist/component/platforms.js +328 -0
- package/dist/component/platforms.js.map +1 -0
- package/dist/component/reports.d.ts +27 -72
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +72 -16
- package/dist/component/reports.js.map +1 -1
- package/dist/component/rollupStore.d.ts +7 -4
- package/dist/component/rollupStore.d.ts.map +1 -1
- package/dist/component/rollupStore.js +37 -17
- package/dist/component/rollupStore.js.map +1 -1
- package/dist/component/rollups.d.ts +27 -8
- package/dist/component/rollups.d.ts.map +1 -1
- package/dist/component/rollups.js +110 -32
- package/dist/component/rollups.js.map +1 -1
- package/dist/component/sanitize.d.ts +5 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +33 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +68 -9
- package/dist/component/schema.js +21 -3
- package/dist/component/schema.js.map +1 -1
- package/dist/component/trackedLinks.d.ts +91 -0
- package/dist/component/trackedLinks.d.ts.map +1 -0
- package/dist/component/trackedLinks.js +314 -0
- package/dist/component/trackedLinks.js.map +1 -0
- package/dist/component/useragent.d.ts +6 -0
- package/dist/component/useragent.d.ts.map +1 -1
- package/dist/component/useragent.js +9 -0
- package/dist/component/useragent.js.map +1 -1
- package/dist/component/validators.d.ts +51 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +11 -11
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +11 -11
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +1 -0
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +104 -47
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts.map +1 -1
- package/dist/tracker/vitals.js +22 -4
- package/dist/tracker/vitals.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -1
- package/docs/upgrading.md +234 -45
- package/llms.txt +9 -4
- package/package.json +2 -2
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +153 -1
- package/src/component/_generated/server.ts +1 -0
- package/src/component/constants.ts +31 -3
- package/src/component/convex.config.ts +3 -0
- package/src/component/coverage.ts +7 -1
- package/src/component/geo.ts +1 -1
- package/src/component/http.ts +71 -2
- package/src/component/identity.ts +41 -2
- package/src/component/ingest.ts +146 -33
- package/src/component/live.ts +51 -4
- package/src/component/origin.ts +273 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +98 -18
- package/src/component/rollupStore.ts +51 -20
- package/src/component/rollups.ts +141 -32
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +24 -3
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +71 -0
- package/src/tracker/generated.ts +11 -11
package/src/component/schema.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
"
|
|
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>;
|
package/src/tracker/generated.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Generated by scripts/build-tracker.mjs. Do not edit.
|
|
2
|
-
export const RASTRO_VERSION = "0.
|
|
3
|
-
export const TRACKER_SOURCE = "(()=>{const
|
|
4
|
-
export const TRACKER_GZIP_BASE64 = "
|
|
5
|
-
export const TRACKER_RAW_BYTES =
|
|
6
|
-
export const TRACKER_GZIP_BYTES =
|
|
7
|
-
export const TRACKER_HASH = "
|
|
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
|
|
9
|
-
export const VITALS_GZIP_BASE64 = "
|
|
10
|
-
export const VITALS_RAW_BYTES =
|
|
11
|
-
export const VITALS_GZIP_BYTES =
|
|
12
|
-
export const VITALS_HASH = "
|
|
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";
|