@indiebob/tracker 0.2.3 → 0.3.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/CHANGELOG.md CHANGED
@@ -6,6 +6,86 @@ This project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.3.0] — 2026-11-01
10
+
11
+ The npm module bundles (`index.js`/`.cjs`, `vue.js`/`.cjs`) are no longer
12
+ minified — your bundler minifies them. A minified bundle's comma-joined
13
+ declarations slipped past Nuxt's auto-import scanner and broke a Nuxt build.
14
+ The script-tag bundle (`indiebob.min.js`) stays minified, under 5 KB.
15
+
16
+ Needs the matching `/api/collect` (it must accept `type: 'engagement'` and
17
+ the new top-level `id` and `engagement` fields) **before** this version is
18
+ served: the 0.2 server rejects a whole batch that contains an engagement event.
19
+
20
+ ### Added
21
+ - **Self-initialising script tag.** `<script src=".../indiebob.min.js"
22
+ data-project="proj_…" defer></script>` initialises itself and tracks the
23
+ page plus every SPA navigation, with no `init()` / `page()` call. Optional
24
+ `data-api` (endpoint), `data-auto-page="false"` (initialise only),
25
+ `data-hash-mode="true"`. A tag without `data-project` behaves exactly as
26
+ before.
27
+ - **Event id.** Every event carries a top-level `id` (21 url-safe
28
+ characters), unchanged across retries and offline replays, so the server
29
+ can store each event once. Server-side events (`/server`) carry one too.
30
+ - **`engagement` event.** Visible-and-focused milliseconds and the deepest
31
+ scroll depth (% of the page) for the page it describes, with that page's
32
+ context. Sent on tab hide, pagehide and right before an SPA navigation,
33
+ only when the scroll grew or ≥ 3 s accumulated since the last one; never a
34
+ pageview.
35
+ - **Automatic file downloads.** A click on a link to a file (pdf, zip, dmg,
36
+ exe, msi, pkg, apk, ipa, deb, rpm, appimage, archives, office files, txt,
37
+ epub, mp3/wav/mp4/mov, iso) fires `file_download` with
38
+ `{ url, path, ext, auto: true }`, on any host; `url` drops the query and
39
+ hash. An outbound download fires only `file_download`. Off with
40
+ `autoDetect: { fileDownloads: false }`.
41
+ - **Owner self-exclude.** `?ib_ignore=1` stops this browser being counted
42
+ (stored as `localStorage.ib_ignore`, with one console line per page load);
43
+ `?ib_ignore=0` undoes it. `bob.isIgnored()` reports it.
44
+ - **`hashMode`** option for `/#/route` routers.
45
+
46
+ ### Changed
47
+ - A `page()` for the path already counted (same pathname + query; a
48
+ hash-only change unless `hashMode`) is skipped.
49
+ - A back/forward-cache restore (`pageshow` with `persisted`) counts as a
50
+ pageview.
51
+ - A page that is prerendered or opened in a background tab counts its first
52
+ pageview when it is first shown.
53
+ - `navigator.webdriver` browsers send nothing.
54
+ - `init()` with a `projectId` that is already running returns that instance
55
+ (the first config wins) instead of starting a second tracker.
56
+ - Builds: the script-tag bundle targets ES2020 (Chrome 80, Firefox 74,
57
+ Safari 13.1) and the npm ESM/CJS build ES2018 (was ES2015 for both) — the
58
+ price of staying under 5 KB gzipped with the features above. Private
59
+ members are shortened at build time.
60
+ - The plain-http fallback for the session id (no `crypto.randomUUID` outside
61
+ a secure context) now uses the SDK's crypto id generator instead of
62
+ `Math.random`.
63
+
64
+ ---
65
+
66
+ ## [0.2.4] — 2026-09-08
67
+
68
+ ### Added
69
+ - **`identify(userId, traits, { isNew })` — tell us, don't make us guess.**
70
+ Without the hint, signup-vs-login is decided by a localStorage flag the SDK
71
+ wrote itself, and that flag cannot exist in a browser that has never called
72
+ `identify()`. So on a product that already has users, the day you install
73
+ `identify()` **every returning user files a phantom `signup`** and the top of
74
+ your funnel is wrong from then on. Your server knows whether it just created
75
+ the user row — pass `isNew` and the guess is skipped.
76
+
77
+ Additive and fully backwards compatible: two-argument calls behave exactly as
78
+ before. The emitted event now carries `properties.auto`, which is `false`
79
+ when the verdict came from your server and `true` when it was inferred, so a
80
+ funnel that looks wrong later can be traced to a guess.
81
+
82
+ ```js
83
+ bob.identify('user_123', { plan: 'pro' }) // inferred
84
+ bob.identify('user_123', { plan: 'pro' }, { isNew: true }) // server truth
85
+ ```
86
+
87
+ ---
88
+
9
89
  ## [0.2.1] — 2026-08-24
10
90
 
11
91
  ### Fixed
package/README.md CHANGED
@@ -10,12 +10,15 @@ npm install @indiebob/tracker
10
10
  pnpm add @indiebob/tracker
11
11
  ```
12
12
 
13
- Or via script tag (CDN):
13
+ Or via script tag (CDN) — one line, nothing else to write:
14
14
 
15
15
  ```html
16
- <script src="https://assets.indiebob.com/tracker/latest/indiebob.min.js"></script>
16
+ <script src="https://assets.indiebob.com/tracker/latest/indiebob.min.js" data-project="proj_your16charId" defer></script>
17
17
  ```
18
18
 
19
+ The tag initialises itself from `data-project` and tracks this page plus every
20
+ SPA navigation. See [Script tag](#vanilla-js-script-tag) for the options.
21
+
19
22
  ## Quick Start
20
23
 
21
24
  Three lines to start tracking:
@@ -39,6 +42,7 @@ const bob = IndieBob.init({
39
42
  respectDnt: true, // Optional. Honor browser Do Not Track. Default: true.
40
43
  respectGpc: true, // Optional. Honor Global Privacy Control. Default: true.
41
44
  autoDetect: true, // Optional. Auto-detect signups, logins, checkouts, etc. Default: true.
45
+ hashMode: false, // Optional. Treat the URL hash as part of the path (/#/route routers). Default: false.
42
46
  })
43
47
  ```
44
48
 
@@ -49,7 +53,8 @@ const bob = IndieBob.init({
49
53
  | `debug` | `boolean` | `false` | Log all events to browser console |
50
54
  | `respectDnt` | `boolean` | `true` | Honor the browser's Do Not Track header |
51
55
  | `respectGpc` | `boolean` | `true` | Honor Global Privacy Control signal (legally binding in CA/GDPR) |
52
- | `autoDetect` | `boolean \| object` | `true` | Auto-detect user intent from URL patterns, form submissions, outbound clicks, and data attributes. See [Auto-Detection](#auto-detection). |
56
+ | `autoDetect` | `boolean \| object` | `true` | Auto-detect user intent from URL patterns, form submissions, outbound clicks, file downloads, and data attributes. See [Auto-Detection](#auto-detection). |
57
+ | `hashMode` | `boolean` | `false` | Count a hash-only change as a new page and report the hash in the path. For hash routers (`/#/settings`). |
53
58
 
54
59
  ## API Reference
55
60
 
@@ -61,6 +66,10 @@ Creates and returns a tracker instance. Call once at app startup.
61
66
  const bob = IndieBob.init({ projectId: 'proj_abc123def456ghij' })
62
67
  ```
63
68
 
69
+ Calling `init()` again with the same `projectId` returns the instance that is
70
+ already running (the first config wins), so nothing is ever counted twice —
71
+ for example a `data-project` script tag plus your own `init()` call.
72
+
64
73
  ---
65
74
 
66
75
  ### `bob.page(overrides?)`
@@ -75,6 +84,12 @@ bob.page({ title: 'Pricing Page' }) // Override title
75
84
 
76
85
  **Auto-detected:** URL path, page title, referrer, UTM parameters, device type.
77
86
 
87
+ A `page()` for the path you are already on (same pathname + query) is skipped,
88
+ so a framework hook that fires twice, or a hash-only change, never counts a
89
+ second view. A page restored from the back/forward cache counts again. A page
90
+ that is prerendered or opened in a background tab is counted when it is first
91
+ shown, not before.
92
+
78
93
  ---
79
94
 
80
95
  ### `bob.track(event, properties?)`
@@ -136,8 +151,22 @@ bob.revenue({
136
151
  bob.optOut() // Stop all tracking. Clears all stored identifiers.
137
152
  bob.optIn() // Re-enable tracking.
138
153
  bob.isOptedOut() // Returns boolean.
154
+ bob.isIgnored() // True if this browser excluded itself with ?ib_ignore=1.
139
155
  ```
140
156
 
157
+ ### Exclude your own visits (`ib_ignore`)
158
+
159
+ Open your site once with `?ib_ignore=1` on the URL
160
+ (`https://yoursite.com/?ib_ignore=1`). That browser stops being counted — on
161
+ every page, from then on — and the console says
162
+ `IndieBob: this browser is excluded from analytics (ib_ignore)` on each page
163
+ load so you can see it working. Open any page with `?ib_ignore=0` to be counted
164
+ again. It is stored in `localStorage` as `ib_ignore`, so it is per browser and
165
+ per site. Your identifiers are kept (unlike `optOut()`).
166
+
167
+ Browsers driven by automation (`navigator.webdriver` — Playwright, Selenium,
168
+ Puppeteer, headless test runners) send nothing at all.
169
+
141
170
  ---
142
171
 
143
172
  ### Debug / Testing
@@ -188,6 +217,40 @@ On signup, login, and checkout pages, form submissions are automatically tracked
188
217
 
189
218
  Clicks on links pointing to external domains automatically fire `outbound_click` with `{ url, hostname }` properties.
190
219
 
220
+ ### File Download Tracking
221
+
222
+ A click on a link whose path ends in a file extension fires `file_download`
223
+ with `{ url, path, ext }` — on your own host or any other (a GitHub release,
224
+ a CDN). `url` is the link without its query string or hash, so a signed
225
+ download token never reaches your analytics. A download on another host fires
226
+ only `file_download`, not `outbound_click` as well.
227
+
228
+ Extensions: `pdf zip dmg exe msi pkg apk ipa deb rpm appimage gz tgz bz2 xz
229
+ tar 7z rar csv xls xlsx doc docx ppt pptx txt epub mp3 wav mp4 mov iso`.
230
+
231
+ ```html
232
+ <a href="/downloads/MyApp-2.1.dmg">Download for Mac</a>
233
+ <!-- → track('file_download', { url: 'https://yoursite.com/downloads/MyApp-2.1.dmg',
234
+ path: '/downloads/MyApp-2.1.dmg', ext: 'dmg', auto: true }) -->
235
+ ```
236
+
237
+ Turn it off with `autoDetect: { fileDownloads: false }`.
238
+
239
+ ### Engagement (time on page and scroll depth)
240
+
241
+ After `page()`, the SDK measures how long the page was **visible and focused**
242
+ (a background tab or another focused window does not count) and how far down
243
+ it was read (the deepest point seen, as a percentage of the page height). It
244
+ sends this as an `engagement` event — never a pageview — with the context of
245
+ the page it describes:
246
+
247
+ - when the tab is hidden or the page is closed (by beacon, like every last event);
248
+ - right before an SPA navigation, for the page being left.
249
+
250
+ It is sent only when there is something new: the scroll depth grew, or at
251
+ least 3 seconds of engaged time accumulated since the last one. The counters
252
+ start again for every page. Nothing to set up.
253
+
191
254
  ### Data Attribute Tracking
192
255
 
193
256
  Add `data-ib-event` to any element for zero-JS custom event tracking:
@@ -214,6 +277,7 @@ const bob = IndieBob.init({
214
277
 
215
278
  // Disable specific features
216
279
  outboundClicks: false,
280
+ fileDownloads: false,
217
281
  formSubmit: false,
218
282
  dataAttributes: false,
219
283
  },
@@ -234,6 +298,7 @@ Set `autoDetect: false` to disable all auto-detection entirely.
234
298
  | `dataAttributes` | `boolean` | `true` | Track clicks on `data-ib-event` elements |
235
299
  | `formSubmit` | `boolean` | `true` | Track form submissions on intent pages |
236
300
  | `outboundClicks` | `boolean` | `true` | Track clicks on external links |
301
+ | `fileDownloads` | `boolean` | `true` | Track clicks on links to files as `file_download` |
237
302
 
238
303
  ---
239
304
 
@@ -288,6 +353,37 @@ useEffect(() => {
288
353
 
289
354
  ### Vanilla JS (Script Tag)
290
355
 
356
+ One tag, before `</head>` on every page:
357
+
358
+ ```html
359
+ <script src="https://assets.indiebob.com/tracker/latest/indiebob.min.js" data-project="proj_abc123def456ghij" defer></script>
360
+ ```
361
+
362
+ It initialises itself and tracks the page plus every SPA navigation
363
+ (`pushState` / back / forward) — no `init()` or `page()` call. Attributes:
364
+
365
+ | Attribute | Meaning |
366
+ |---|---|
367
+ | `data-project` | Your project ID. Without it the tag does nothing on its own. |
368
+ | `data-api` | Ingestion endpoint override (self-hosted). |
369
+ | `data-auto-page="false"` | Initialise only; you call `page()` yourself. |
370
+ | `data-hash-mode="true"` | Same as the `hashMode` option. |
371
+
372
+ To call `track()` / `identify()` from your own script, ask for the running
373
+ instance — `init()` with the same project ID returns it. With `defer`, the
374
+ global exists once the document has been parsed:
375
+
376
+ ```html
377
+ <script>
378
+ document.addEventListener('DOMContentLoaded', function () {
379
+ var bob = IndieBob.init({ projectId: 'proj_abc123def456ghij' })
380
+ bob.track('newsletter_signup')
381
+ })
382
+ </script>
383
+ ```
384
+
385
+ The classic install keeps working unchanged:
386
+
291
387
  ```html
292
388
  <script src="https://assets.indiebob.com/tracker/latest/indiebob.min.js"></script>
293
389
  <script>
@@ -296,6 +392,11 @@ useEffect(() => {
296
392
  </script>
297
393
  ```
298
394
 
395
+ If you add `data-project` to that same tag (leave out `defer` while the inline
396
+ call is there — a deferred script runs after it), nothing is counted twice:
397
+ `init()` returns the running instance and the second `page()` for the same
398
+ path is skipped.
399
+
299
400
  ### Server-Side (Node.js)
300
401
 
301
402
  ```ts
@@ -330,6 +431,12 @@ UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_con
330
431
  - **Last-touch** attribution in `_ib_last_utm` — updated each time UTMs are present in the URL
331
432
  - Enables "which campaign drove this paying customer?" — the core attribution question
332
433
 
434
+ ### Event IDs
435
+
436
+ Every event carries a random, url-safe `id` (21 characters). It stays the same
437
+ when a batch is retried or replayed from the offline queue, so the server
438
+ stores each event once.
439
+
333
440
  ### Event Batching
334
441
 
335
442
  Events are batched for efficiency:
@@ -360,6 +467,8 @@ The SDK respects:
360
467
  | UTM params | From URL query string | Object |
361
468
  | Screen size | `window.innerWidth/Height` | Numbers |
362
469
  | Device type | Derived from screen size | `'desktop'` \| `'mobile'` \| `'tablet'` |
470
+ | Engaged time | Visible-and-focused milliseconds on the page (`engagement` event) | Number |
471
+ | Scroll depth | Deepest point of the page seen, % of its height (`engagement` event) | Number |
363
472
  | Browser locale | `navigator.language` | String |
364
473
  | Timezone | `Intl.DateTimeFormat().resolvedOptions().timeZone` | String |
365
474
  | Country | Derived server-side from IP | String (e.g. `'US'`) |
@@ -381,6 +490,7 @@ The SDK respects:
381
490
  | `onboarding_view` | User visits an onboarding page |
382
491
  | `contact_view` | User visits a contact page |
383
492
  | `outbound_click` | User clicks an external link |
493
+ | `file_download` | User clicks a link to a file (pdf, zip, dmg, …) |
384
494
  | Custom via `data-ib-event` | User clicks an element with the attribute |
385
495
 
386
496
  ### NOT collected
@@ -401,6 +511,9 @@ The SDK does not use cookies and does not collect personal data by default. The
401
511
 
402
512
  When a user calls `optOut()`, all stored keys are cleared: `_ib_anon`, `_ib_session`, `_ib_first_utm`, `_ib_last_utm`, `_ib_referrer`, `_ib_queue`.
403
513
 
514
+ `ib_ignore` (the site owner's own self-exclusion, see above) is the one key the
515
+ SDK writes that is not an identifier; it is removed with `?ib_ignore=0`.
516
+
404
517
  ### CCPA (California)
405
518
 
406
519
  Global Privacy Control (GPC) is automatically honored. If a user has GPC enabled in their browser, the SDK sends no events. This is the legally correct behavior under CCPA as amended by CPRA.
@@ -441,14 +554,20 @@ This SDK follows [Semantic Versioning](https://semver.org/).
441
554
 
442
555
  ## Bundle Size
443
556
 
444
- | Bundle | Size (gzip) |
557
+ | Bundle | Size (gzip -9) |
445
558
  |---|---|
446
- | Browser IIFE (`tracker.js`) | < 5KB |
447
- | ESM tree-shakeable | < 4KB |
559
+ | Browser IIFE (`indiebob.min.js`) | < 5KB (5,076 bytes in 0.3.0) |
560
+ | ESM (`index.js`) | ~5KB (5,135 bytes in 0.3.0; your bundler minifies and tree-shakes it further) |
448
561
  | Server (Node.js) | No limit |
449
562
 
450
563
  Zero runtime dependencies.
451
564
 
565
+ ## Browser Support
566
+
567
+ The script-tag bundle targets ES2020: Chrome/Edge 80+, Firefox 74+, Safari
568
+ 13.1+ (iOS 13.4+). The npm build (ESM/CJS) targets ES2018 and is transpiled
569
+ further by your bundler if you need older browsers.
570
+
452
571
  ---
453
572
 
454
573
  ## License