weathervane 0.1.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 ADDED
@@ -0,0 +1,471 @@
1
+ # Weathervane
2
+
3
+ **Tracks everything. Sends nothing.**
4
+
5
+ Weathervane is a ~6 KB, dependency-free JavaScript tracking layer. It watches user behavior — pageviews, content exposure, clicks, forms, sessions, web vitals — and **emits everything as structured browser `CustomEvent`s**. It makes zero network requests and sets zero cookies.
6
+
7
+ You write the last mile. Forward events to GA4, PostHog, Mixpanel, your warehouse, a webhook — or all of them at once — with a few lines:
8
+
9
+ ```js
10
+ window.addEventListener('vane:event', (e) => {
11
+ console.log(e.detail.event_name, e.detail);
12
+ // → forward anywhere you like
13
+ });
14
+ ```
15
+
16
+ ## Why Weathervane?
17
+
18
+ Most analytics scripts bundle three jobs: *what to track*, *how to structure it*, and *where to send it*. That bundling is the lock-in. Weathervane does the first two — the hard, repetitive part — and hands you the third.
19
+
20
+ - **One tracking layer, N destinations.** Instrument your site once with `data-vane-*` attributes. Forward to GA4 today, add PostHog tomorrow, swap in your warehouse next year — without touching your markup.
21
+ - **Inspect before anything ships.** Every event is visible in the browser before *you* decide it leaves. Scrub, sample, or drop events in your forwarder. Perfect chokepoint for consent logic.
22
+ - **No cookies, no requests, no vendor.** Weathervane itself has nothing to consent to, nothing to block, and nothing to migrate away from. IDs live in `localStorage`.
23
+ - **Tracking quality you'd otherwise build by hand.** Cumulative, pause-aware content exposure timing; form abandonment with engagement time; SPA navigation; shadow DOM support. This is the boilerplate everyone rewrites per project, done once.
24
+
25
+ ### "Why not just use Google Tag Manager?"
26
+
27
+ GTM is a tag *loader* — it injects vendor scripts and routes dataLayer pushes, but it doesn't *generate* rich behavioral events. Out of the box GTM can't tell you that a hero section was visible for 2.3 cumulative seconds, that a checkout form was abandoned after 14 seconds of engagement, or that a CTA inside a web component was clicked. Weathervane is the tracking *engine* that produces those events; GTM (or anything else) can be the router. They compose: `window.addEventListener('vane:event', e => dataLayer.push({ event: 'vane', ...e.detail }))`.
28
+
29
+ ## ✨ Key Features
30
+
31
+ - 🔌 **Zero backend, zero network** — events are emitted in the browser, never sent anywhere
32
+ - 🍪 **Zero cookies** — client & session IDs in `localStorage` (in-memory fallback); nothing for a cookie banner to announce
33
+ - 🎯 **Automatic content tracking** — serve / view / click lifecycle via `data-vane-*` attributes
34
+ - 👁️ **Sophisticated viewport detection** — IntersectionObserver-based, cumulative & tab-switch-aware exposure timing, percentage-fill handling for content taller than the viewport
35
+ - 🌒 **Shadow DOM support** — tracks content, clicks, and forms inside open shadow roots (web components)
36
+ - 🔗 **Link & form tracking** — automatic `link_click`, `form_submit`, and `form_abandon` events
37
+ - 📊 **Web vitals** — FCP, LCP, CLS, and FID included on every payload
38
+ - 📱 **SPA support** — automatic `pageview_dynamic` for pushState / replaceState / popstate / hashchange
39
+ - 🆔 **Enterprise-grade IDs** — ULID event IDs (time-sortable), UUID v4 client / session / page-view IDs
40
+ - 🛡️ **Lightweight & dependency-free** — one file, no build step, ~6 KB gzipped minified
41
+
42
+ ## 🚀 Quick Start
43
+
44
+ ### 1. Install
45
+
46
+ **Script tag (simplest):**
47
+
48
+ ```html
49
+ <script src="/path/to/weathervane.min.js"></script>
50
+ ```
51
+
52
+ That's it. Weathervane auto-initializes with sensible defaults and immediately starts emitting events (initial `pageview`, `session_start` on new sessions, link clicks, etc.).
53
+
54
+ **With configuration:**
55
+
56
+ ```html
57
+ <script>
58
+ // Define config before the script loads…
59
+ window.vaneConfig = { debug: true, sessionTimeout: 30 };
60
+ </script>
61
+ <script src="/path/to/weathervane.min.js"></script>
62
+ ```
63
+
64
+ **Manual initialization:**
65
+
66
+ ```html
67
+ <script src="/path/to/weathervane.min.js" data-vane-auto="false"></script>
68
+ <script>
69
+ window.vane.init({ debug: true });
70
+ </script>
71
+ ```
72
+
73
+ **npm / bundler:**
74
+
75
+ ```js
76
+ import 'weathervane'; // side-effect import; attaches window.vane and auto-inits
77
+ ```
78
+
79
+ ### 2. Listen for events
80
+
81
+ Every event is dispatched on `window` **twice**:
82
+
83
+ | Event type | Fires for | Use when |
84
+ |---|---|---|
85
+ | `vane:event` | every event | one listener forwards everything |
86
+ | `vane:<event_name>` | that event only (e.g. `vane:form_submit`) | you only care about specific events |
87
+
88
+ ```js
89
+ // Catch everything
90
+ window.addEventListener('vane:event', (e) => myDestination.send(e.detail));
91
+
92
+ // Or just one event type
93
+ window.addEventListener('vane:content_view', (e) => {
94
+ console.log('Viewed:', e.detail.properties.content_name);
95
+ });
96
+ ```
97
+
98
+ Or use the built-in helper, which can also **replay** events that fired before your listener attached (e.g. the initial `pageview`):
99
+
100
+ ```js
101
+ const unsubscribe = vane.on('*', (payload) => { /* ... */ }, { replay: true });
102
+ vane.on('link_click', (payload) => { /* ... */ });
103
+ unsubscribe(); // stop listening
104
+ ```
105
+
106
+ ### 3. (Optional) Annotate your markup
107
+
108
+ ```html
109
+ <div data-vane-content="hero-banner"
110
+ data-vane-type="marketing"
111
+ data-vane-exposure="2000">
112
+ <h1>Welcome!</h1>
113
+ <button data-vane-content-click="cta-primary">Get Started</button>
114
+ </div>
115
+ ```
116
+
117
+ ## 🔀 Forwarding Recipes (the last mile)
118
+
119
+ All recipes use the catch-all listener; trim to specific `vane:<name>` events as needed.
120
+
121
+ **Google Analytics 4 (gtag):**
122
+
123
+ ```js
124
+ window.addEventListener('vane:event', (e) => {
125
+ const { event_name, properties, page } = e.detail;
126
+ gtag('event', event_name, { ...properties, page_path: page.path });
127
+ });
128
+ ```
129
+
130
+ **Google Tag Manager (dataLayer):**
131
+
132
+ ```js
133
+ window.addEventListener('vane:event', (e) => {
134
+ dataLayer.push({ event: 'vane.' + e.detail.event_name, vane: e.detail });
135
+ });
136
+ ```
137
+
138
+ **PostHog:**
139
+
140
+ ```js
141
+ window.addEventListener('vane:event', (e) => {
142
+ const d = e.detail;
143
+ posthog.capture(d.event_name, {
144
+ ...d.properties,
145
+ $current_url: d.page.url,
146
+ vane_session_id: d.session_id,
147
+ });
148
+ });
149
+ ```
150
+
151
+ **Mixpanel:**
152
+
153
+ ```js
154
+ window.addEventListener('vane:event', (e) => {
155
+ const d = e.detail;
156
+ mixpanel.track(d.event_name, { ...d.properties, page: d.page.path });
157
+ });
158
+ // keep identities in sync
159
+ window.addEventListener('vane:user_identify', (e) => mixpanel.identify(e.detail.user_id));
160
+ ```
161
+
162
+ **Amplitude:**
163
+
164
+ ```js
165
+ window.addEventListener('vane:event', (e) => {
166
+ amplitude.track(e.detail.event_name, { ...e.detail.properties, page: e.detail.page.path });
167
+ });
168
+ ```
169
+
170
+ **Segment (and Segment-compatible: RudderStack, Jitsu):**
171
+
172
+ ```js
173
+ window.addEventListener('vane:event', (e) => {
174
+ const d = e.detail;
175
+ if (d.event_name === 'pageview' || d.event_name === 'pageview_dynamic') {
176
+ analytics.page(d.page.title, { path: d.page.path, url: d.page.url });
177
+ } else {
178
+ analytics.track(d.event_name, d.properties);
179
+ }
180
+ });
181
+ ```
182
+
183
+ **Plausible (custom events — props must be flat scalars):**
184
+
185
+ ```js
186
+ window.addEventListener('vane:event', (e) => {
187
+ const d = e.detail;
188
+ const props = {};
189
+ for (const [k, v] of Object.entries(d.properties)) {
190
+ if (v !== null && typeof v !== 'object') props[k] = v;
191
+ }
192
+ plausible(d.event_name, { props });
193
+ });
194
+ ```
195
+
196
+ **Umami:**
197
+
198
+ ```js
199
+ window.addEventListener('vane:event', (e) => {
200
+ umami.track(e.detail.event_name, e.detail.properties);
201
+ });
202
+ ```
203
+
204
+ **Your own API (batched, survives page close):**
205
+
206
+ ```js
207
+ const queue = [];
208
+ window.addEventListener('vane:event', (e) => queue.push(e.detail));
209
+ function flush() {
210
+ if (queue.length) navigator.sendBeacon('/api/events', JSON.stringify(queue.splice(0)));
211
+ }
212
+ setInterval(flush, 5000);
213
+ addEventListener('visibilitychange', () => document.visibilityState === 'hidden' && flush());
214
+ ```
215
+
216
+ **Slack webhook (e.g. ping yourself on conversions):**
217
+
218
+ ```js
219
+ window.addEventListener('vane:form_submit', (e) => {
220
+ if (e.detail.properties.form_goal !== 'conversion') return;
221
+ fetch(SLACK_WEBHOOK_URL, {
222
+ method: 'POST',
223
+ body: JSON.stringify({ text: `🎉 ${e.detail.properties.form_name} submitted` }),
224
+ });
225
+ });
226
+ ```
227
+
228
+ **Multiple destinations?** Just add multiple listeners — that's the whole point.
229
+
230
+ ## 📦 Event Payload
231
+
232
+ Every event's `detail` has the same structure:
233
+
234
+ ```jsonc
235
+ {
236
+ "event_id": "01JXC4N9Z3T5W8...", // ULID — time-sortable
237
+ "event_name": "content_view",
238
+ "timestamp": "2026-06-10T18:24:31.512Z",
239
+ "client_id": "f47ac10b-...", // persistent (localStorage)
240
+ "session_id": "6ba7b810-...", // rolling 30-min session
241
+ "page_view_id": "9c2f1ab4-...", // regenerated per page / SPA route
242
+ "user_id": null, // set via vane.setUserId()
243
+
244
+ "properties": { // event-specific data
245
+ "content_name": "hero-banner",
246
+ "content_type": "marketing",
247
+ "segment": "homepage",
248
+ "content_instance": "uuid...",
249
+ "content_depth": 12,
250
+ "exposure_limit": 2000,
251
+ "exposure_time": 2014
252
+ },
253
+
254
+ "page": { "url", "path", "title", "referrer", "search", "hash" },
255
+ "device": { "browser_name", "browser_version", "device_type", "language",
256
+ "timezone", "screen_width", "screen_height",
257
+ "viewport_width", "viewport_height", "device_memory",
258
+ "hardware_concurrency", "cookie_enabled", "online", "user_agent" },
259
+ "utm": { "utm_source", "utm_medium", "..." }, // null when absent
260
+ "performance": { "first_contentful_paint", "largest_contentful_paint",
261
+ "cumulative_layout_shift", "first_input_delay" },
262
+ "engagement": { "scroll_depth": 45, "time_on_page": 12840 },
263
+ "context": { }, // your vane.setContext() data
264
+ "sdk": { "name": "vane", "version": "1.1.0" }
265
+ }
266
+ ```
267
+
268
+ ## 📊 Event Types
269
+
270
+ **Page events**
271
+ - `pageview` — initial page view (fires on DOM ready); `properties` include `page_load_time`, `dom_content_loaded_time`, `navigation_type`, `connection_type`
272
+ - `pageview_dynamic` — SPA navigation; `properties.navigation_trigger` is `pushState` / `replaceState` / `popstate` / `hashchange`
273
+ - `session_start` — new session; `properties.reason` is `new` / `timeout` / `manual`
274
+
275
+ **Content events**
276
+ - `content_serve` — tracked content appeared in the DOM
277
+ - `content_view` — content was visible long enough (cumulative, pause-aware); includes `exposure_time`
278
+ - `content_click` — a `data-vane-content-click` element was clicked
279
+
280
+ **Interaction events**
281
+ - `link_click` — any `<a href>` click; includes `url`, `text`, `target`, `link_type` (web/email/phone), `is_external`
282
+ - `form_submit` — any form submission; includes form metadata and `completion_time`
283
+ - `form_abandon` — user engaged with a form for 3+ seconds and left without submitting (fires on `pagehide` or SPA navigation); includes `engagement_time`
284
+
285
+ **User events**
286
+ - `user_identify` — fired by `setUserId()`
287
+
288
+ **Custom events**
289
+ - anything you pass to `vane.track(name, properties)`
290
+
291
+ ## 🏷️ Data Attributes Reference
292
+
293
+ ### Content tracking
294
+
295
+ | Attribute | Required | Description |
296
+ |---|---|---|
297
+ | `data-vane-content="name"` | ✅ | Marks an element for serve/view/click tracking |
298
+ | `data-vane-type="type"` | — | Content category (`marketing`, `product`, `blog`, …) |
299
+ | `data-vane-segment="segment"` | — | Grouping for segmented analysis |
300
+ | `data-vane-exposure="ms"` | — | Visible time required for `content_view` (default 1000) |
301
+ | `data-vane-content-click="id"` | — | Tracks clicks on elements inside (or outside) content blocks |
302
+
303
+ ```html
304
+ <section data-vane-content="product-showcase"
305
+ data-vane-type="product"
306
+ data-vane-segment="homepage"
307
+ data-vane-exposure="1500">
308
+ <h2>Featured Products</h2>
309
+ <button data-vane-content-click="product-1-buy">Buy Now</button>
310
+ <a href="/products" data-vane-content-click="view-all">View All</a>
311
+ </section>
312
+ ```
313
+
314
+ **Lifecycle:** *serve* (in DOM) → *view* (visible for the exposure time, cumulative across scroll-aways and tab switches) → *click*.
315
+
316
+ **Viewport rules:** elements that fit in the viewport must be ~fully visible; elements **taller than the viewport** count as visible while they fill ≥65% of it (configurable via `largeContentViewportFill`). Dynamically injected content is discovered automatically via MutationObserver.
317
+
318
+ ### Form tracking
319
+
320
+ All optional; they enrich `form_submit` / `form_abandon` events:
321
+
322
+ | Attribute | Example values |
323
+ |---|---|
324
+ | `data-vane-form-type` | `signup`, `contact`, `checkout`, `newsletter` |
325
+ | `data-vane-form-category` | `marketing`, `support`, `sales` |
326
+ | `data-vane-form-step` | `1`, `billing` |
327
+ | `data-vane-form-funnel` | `registration`, `checkout` |
328
+ | `data-vane-form-value` | `199.99`, `lead` |
329
+ | `data-vane-form-goal` | `lead-generation`, `conversion` |
330
+ | `data-vane-form-segment` | `free-users`, `enterprise` |
331
+
332
+ ```html
333
+ <form data-vane-form-type="checkout"
334
+ data-vane-form-funnel="purchase"
335
+ data-vane-form-step="billing"
336
+ data-vane-form-value="199.99"
337
+ data-vane-form-goal="conversion">
338
+ ...
339
+ </form>
340
+ ```
341
+
342
+ Weathervane never reads or emits **field values** — only metadata (field count, types, required/optional counts).
343
+
344
+ ## 🌒 Shadow DOM & Web Components
345
+
346
+ Weathervane tracks inside **open shadow roots** automatically (`trackShadowDom: true` by default):
347
+
348
+ - Existing shadow roots are discovered on the initial scan; roots created later are caught by instrumenting `Element.prototype.attachShadow`.
349
+ - **Declarative shadow DOM** works too, including post-load: subtrees with parser-created roots (`<template shadowrootmode="open">` via `setHTMLUnsafe`, `parseHTMLUnsafe`, or streamed HTML) are discovered when they enter the DOM.
350
+ - `data-vane-content` elements inside shadow roots get full serve/view/click tracking.
351
+ - Clicks are resolved via `event.composedPath()`, so `data-vane-content-click` works across shadow boundaries (a tracked button inside a component can attribute to a content container outside it).
352
+ - `submit` events don't cross shadow boundaries, so Weathervane attaches a submit listener inside each tracked root — forms in web components emit `form_submit` like any other.
353
+
354
+ **Closed** shadow roots are intentionally private and are not tracked. Set `trackShadowDom: false` to disable all of this (including the `attachShadow` instrumentation).
355
+
356
+ ## ⚙️ Configuration
357
+
358
+ All options with their defaults:
359
+
360
+ ```js
361
+ vane.init({
362
+ // Emission
363
+ eventPrefix: 'vane', // events fire as `${prefix}:event` / `${prefix}:<name>`
364
+ historySize: 100, // events kept for on(..., { replay: true }) / getHistory()
365
+
366
+ // Feature toggles
367
+ enableAutoPageview: true, // initial `pageview` on DOM ready
368
+ enableDynamicPageview: true, // SPA `pageview_dynamic` events
369
+ enableContentTracking: true, // data-vane-content lifecycle
370
+ enableLinkTracking: true, // automatic link_click
371
+ enableFormTracking: true, // form_submit / form_abandon
372
+ enableWebVitals: true, // FCP / LCP / CLS / FID collection
373
+ trackShadowDom: true, // open shadow root tracking
374
+
375
+ // Session management
376
+ sessionTimeout: 30, // minutes of inactivity before a new session
377
+
378
+ // Content tracking
379
+ contentExposureLimit: 1000, // default ms required for content_view
380
+ largeContentViewportFill: 0.65, // viewport-fill fraction for tall content
381
+
382
+ // Form tracking
383
+ formAbandonThreshold: 3000, // min engagement ms before form_abandon
384
+
385
+ // Development
386
+ debug: false // console logging of every emitted event
387
+ });
388
+ ```
389
+
390
+ ## 🛠️ API Reference
391
+
392
+ | Method | Description |
393
+ |---|---|
394
+ | `vane.init(options?)` | Initialize (called automatically unless disabled) |
395
+ | `vane.track(name, properties?)` | Emit a custom event; returns the payload |
396
+ | `vane.trackPageView()` | Manually emit a `pageview` (new `page_view_id`, resets scroll depth & timers) |
397
+ | `vane.on(name, cb, { replay? })` | Subscribe (`'*'` for all); returns an unsubscribe function |
398
+ | `vane.setUserId(id)` / `getUserId()` | Set/get user ID; setting emits `user_identify` |
399
+ | `vane.setContext(key, value)` / `getContext()` / `clearContext()` | Global context attached to every payload |
400
+ | `vane.newSession()` | Force a new session (emits `session_start`) |
401
+ | `vane.getClientId()` / `getSessionId()` / `getPageViewId()` | Current identifiers |
402
+ | `vane.getHistory()` | Last N emitted payloads (see `historySize`) |
403
+ | `vane.getContentState()` | Serve/view/click state of all tracked content |
404
+ | `vane.isReady()` | Whether the SDK is initialized |
405
+ | `vane.destroy()` | Remove all listeners/observers/instrumentation and stop tracking |
406
+
407
+ `track()` calls made before `init()` are queued and emitted once initialized, so the classic async-loader stub pattern (`window.vane = { _queue: [...] }`) also works.
408
+
409
+ ## 🆔 ID & Session Management (cookie-free)
410
+
411
+ Weathervane sets **no cookies**. All identifiers live in `localStorage`, with a graceful in-memory fallback when storage is unavailable (private mode restrictions, blocked storage):
412
+
413
+ - **Client ID** — UUID v4, persists across sessions. Stored under `vane_cid`.
414
+ - **Session ID** — UUID v4, rolling window (default 30 min), renewed on clicks/keys/scroll. Stored under `vane_sid`. A new session emits `session_start`.
415
+ - **Page View ID** — UUID v4, regenerated on every page load and SPA navigation.
416
+ - **Event ID** — ULID, lexicographically sortable by time for friendly time-series storage.
417
+
418
+ > **Safari note:** WebKit's ITP caps *all* script-writable storage (localStorage included) at 7 days without user interaction with your site. Expect client IDs to rotate more often on Safari than elsewhere — a limitation of every client-only analytics approach.
419
+
420
+ > **Privacy note:** no cookies means nothing for cookie-scanner tools to flag, but a persistent client ID is still pseudonymous personal data under GDPR. The clean part: *you* control whether IDs ever leave the browser, and your forwarder is a single chokepoint for consent gating.
421
+
422
+ ## 📱 SPA Support
423
+
424
+ `pushState`, `replaceState`, `popstate`, and `hashchange` are detected automatically and emit `pageview_dynamic` with a `navigation_trigger`. Each navigation regenerates the `page_view_id`, resets scroll depth and time-on-page, re-parses UTM parameters, and flushes any pending `form_abandon`.
425
+
426
+ To handle routing yourself:
427
+
428
+ ```js
429
+ vane.init({ enableDynamicPageview: false });
430
+ myRouter.on('change', () => vane.trackPageView());
431
+ ```
432
+
433
+ ## 🚀 Demo
434
+
435
+ Open [demo/index.html](demo/index.html) via any static server for a live, real-time event
436
+ console next to a full test matrix:
437
+
438
+ - **Part 1 — Light DOM:** content exposure (including the tall-content 65% rule), forms with
439
+ submit/abandon, automatic link tracking, and post-load dynamic injection.
440
+ - **Part 2 — Shadow DOM:** a component whose root is created *before* Weathervane initializes,
441
+ static declarative shadow DOM, nested roots two levels deep, cross-boundary click attribution,
442
+ post-load injection via both `attachShadow()` and `setHTMLUnsafe()` — plus a **closed root**
443
+ with identical attributes proving what's intentionally *not* tracked.
444
+ - **Part 3 — Page-level:** SPA navigation and the manual `track()` / identity API.
445
+
446
+ Each block lists the events it should emit, so you can verify behavior against expectations:
447
+
448
+ ```bash
449
+ npm run demo # serves the repo at http://localhost:4173 → open /demo/
450
+ ```
451
+
452
+ ## 🌐 Browser Support
453
+
454
+ Chrome 60+, Firefox 63+, Safari 12.1+, Edge 79+, iOS Safari 12.2+. Uses `IntersectionObserver`, `MutationObserver`, `PerformanceObserver` (web vitals degrade gracefully), `CustomEvent`, `composedPath`, and the History API. localStorage has a graceful in-memory fallback.
455
+
456
+ ## 🐛 Debugging
457
+
458
+ ```js
459
+ vane.init({ debug: true });
460
+ ```
461
+
462
+ Logs every emitted event to the console. Common gotchas:
463
+
464
+ - **Listener attached too late?** The initial `pageview` fires on DOM ready — use `vane.on('*', cb, { replay: true })` or `vane.getHistory()` to catch up.
465
+ - **Content not viewing?** Check the element is actually visible and meets the exposure time; tall elements need to fill 65% of the viewport.
466
+ - **No `form_abandon`?** It requires 3+ seconds of engagement and fires on `pagehide`/SPA navigation, not on blur.
467
+ - **Web component not tracked?** Only *open* shadow roots are trackable; closed roots are invisible by design.
468
+
469
+ ## License
470
+
471
+ MIT
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "weathervane",
3
+ "version": "0.1.0",
4
+ "description": "Tracks everything, sends nothing. Lightweight, cookie-free, zero-backend analytics — pageviews, content exposure, clicks, forms, sessions, and web vitals emitted as structured browser CustomEvents you can forward anywhere.",
5
+ "author": "Alec Rothman",
6
+ "license": "MIT",
7
+ "main": "weathervane.js",
8
+ "browser": "weathervane.js",
9
+ "files": [
10
+ "weathervane.js",
11
+ "weathervane.min.js",
12
+ "weathervane.min.js.gz",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "npx terser weathervane.js --compress --mangle -o weathervane.min.js && gzip -9 -kf weathervane.min.js",
17
+ "demo": "python3 -m http.server 4173"
18
+ },
19
+ "keywords": [
20
+ "analytics",
21
+ "tracking",
22
+ "events",
23
+ "custom-events",
24
+ "web-vitals",
25
+ "privacy",
26
+ "cookie-free",
27
+ "lightweight"
28
+ ]
29
+ }