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 +471 -0
- package/package.json +29 -0
- package/weathervane.js +1026 -0
- package/weathervane.min.js +16 -0
- package/weathervane.min.js.gz +0 -0
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
|
+
}
|