@indiebob/tracker 0.3.0 → 0.4.1

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 CHANGED
@@ -1,574 +1,178 @@
1
1
  # @indiebob/tracker
2
2
 
3
- Privacy-first analytics SDK for web applications. Under 5KB gzipped. No cookies. GDPR/CCPA/GPC compliant.
3
+ The analytics script for [IndieBob](https://www.indiebob.com): page views, traffic sources, events,
4
+ signups and revenue. About 5.7 KB gzipped, no dependencies, no cookies. By default it stores nothing
5
+ in the visitor's browser.
4
6
 
5
- ## Installation
7
+ ## Install
6
8
 
7
- ```bash
8
- npm install @indiebob/tracker
9
- # or
10
- pnpm add @indiebob/tracker
11
- ```
12
-
13
- Or via script tag (CDN) — one line, nothing else to write:
9
+ **Script tag** — one line before `</head>`. It counts every page, including navigation in a
10
+ single-page app.
14
11
 
15
12
  ```html
16
- <script src="https://assets.indiebob.com/tracker/latest/indiebob.min.js" data-project="proj_your16charId" defer></script>
17
- ```
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
-
22
- ## Quick Start
23
-
24
- Three lines to start tracking:
25
-
26
- ```js
27
- import { IndieBob } from '@indiebob/tracker'
28
-
29
- const bob = IndieBob.init({ projectId: 'proj_your16charId' })
30
- bob.page()
13
+ <script src="https://assets.indiebob.com/tracker/latest/indiebob.min.js" data-project="proj_…" defer></script>
31
14
  ```
32
15
 
33
- That's it. Page views are now tracked. UTM parameters are auto-captured.
34
-
35
- ## Configuration
36
-
37
- ```ts
38
- const bob = IndieBob.init({
39
- projectId: 'proj_your16charId', // Required. From your IndieBob dashboard.
40
- endpoint: 'https://indiebob.com/api/collect', // Optional. Default shown.
41
- debug: false, // Optional. Logs events to console when true.
42
- respectDnt: true, // Optional. Honor browser Do Not Track. Default: true.
43
- respectGpc: true, // Optional. Honor Global Privacy Control. Default: true.
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.
46
- })
47
- ```
48
-
49
- | Option | Type | Default | Description |
50
- |---|---|---|---|
51
- | `projectId` | `string` | **required** | Your SDK project ID from the dashboard |
52
- | `endpoint` | `string` | `'https://indiebob.com/api/collect'` | Override for self-hosted instances |
53
- | `debug` | `boolean` | `false` | Log all events to browser console |
54
- | `respectDnt` | `boolean` | `true` | Honor the browser's Do Not Track header |
55
- | `respectGpc` | `boolean` | `true` | Honor Global Privacy Control signal (legally binding in CA/GDPR) |
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`). |
58
-
59
- ## API Reference
60
-
61
- ### `IndieBob.init(config)`
16
+ | Attribute | |
17
+ |---|---|
18
+ | `data-project` | Your project id (IndieBob › your project › Connect). Required. |
19
+ | `data-persistence="local"` | Full attribution (see below). Only with the visitor's consent. |
20
+ | `data-hash-mode="true"` | Your routes live after a `#` (`/#/pricing`). |
21
+ | `data-auto-page="false"` | Start the tracker but count pages yourself. |
22
+ | `data-api="…"` | Send to another endpoint. |
62
23
 
63
- Creates and returns a tracker instance. Call once at app startup.
24
+ **npm**
64
25
 
65
- ```ts
66
- const bob = IndieBob.init({ projectId: 'proj_abc123def456ghij' })
26
+ ```sh
27
+ npm install @indiebob/tracker
67
28
  ```
68
29
 
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
-
73
- ---
74
-
75
- ### `bob.page(overrides?)`
76
-
77
- Track a page view. Call this on every page load and SPA navigation.
78
-
79
30
  ```ts
80
- bob.page() // Auto-detects path, title, referrer
81
- bob.page({ path: '/pricing' }) // Override path
82
- bob.page({ title: 'Pricing Page' }) // Override title
83
- ```
84
-
85
- **Auto-detected:** URL path, page title, referrer, UTM parameters, device type.
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
-
93
- ---
94
-
95
- ### `bob.track(event, properties?)`
96
-
97
- Track a custom event.
31
+ import { IndieBob } from '@indiebob/tracker'
98
32
 
99
- ```ts
100
- bob.track('signup_started')
101
- bob.track('trial_activated', { plan: 'pro' })
102
- bob.track('feature_used', { feature: 'csv_export', count: 3 })
103
- bob.track('checkout_completed', { revenue: 29.00, currency: 'USD', plan: 'pro-monthly' })
33
+ const bob = IndieBob.init({ projectId: 'proj_…' })
34
+ bob.page()
104
35
  ```
105
36
 
106
- **`event`** — string, required. Max 256 characters. Use `snake_case` by convention.
37
+ ## Privacy mode and full attribution
107
38
 
108
- **`properties`** — plain object, optional. Values must be JSON-serializable.
109
-
110
- ---
39
+ | | Privacy mode (default) | Full attribution |
40
+ |---|---|---|
41
+ | Stored in the visitor's browser | nothing | a visitor id, a session id, the first and last UTM, the first referrer |
42
+ | A visitor is recognised | for one day | across days |
43
+ | A signup is credited to a visit on an earlier day | no | yes |
44
+ | Needs the visitor's consent in the EU | no | yes |
111
45
 
112
- ### `bob.identify(userId, traits?)`
46
+ In privacy mode IndieBob's server identifies a visitor by hashing a random daily salt together with
47
+ the project, the IP address and the user agent. Each day's salt is deleted, and the IP address is
48
+ not stored.
113
49
 
114
- Associate the current anonymous visitor with a known user ID. Call this after login or signup.
50
+ Full attribution stores an id on the visitor's device, which needs consent in the EU (ePrivacy
51
+ Directive, Art. 5(3); localStorage counts the same as a cookie). Switch it on from your consent
52
+ banner:
115
53
 
116
54
  ```ts
117
- bob.identify('user_123', {
118
- email: 'jane@example.com',
119
- name: 'Jane Smith',
120
- plan: 'pro',
121
- createdAt: '2026-01-15',
122
- })
55
+ bob.setPersistence('local') // the visitor accepted
56
+ bob.setPersistence('none') // consent withdrawn: removes everything the tracker stored
123
57
  ```
124
58
 
125
- **`userId`** — string, required. Your internal user ID. Never pass PII as the userId directly.
126
-
127
- **`traits`** — plain object, optional. Can include `email` for attribution linking.
128
-
129
- After `identify()`, all subsequent events are linked to this userId until the session ends.
130
-
131
- ---
132
-
133
- ### `bob.revenue(data)`
59
+ If consent is already known when the page loads, pass `persistence: 'local'` to `init()` or put
60
+ `data-persistence="local"` on the tag.
134
61
 
135
- Track a revenue event. Use alongside Stripe webhooks for complete attribution.
62
+ ## Configuration
136
63
 
137
64
  ```ts
138
- bob.revenue({
139
- amount: 29.00, // Required. Positive number.
140
- currency: 'USD', // Optional. Default: 'USD'. ISO 4217 code.
141
- plan: 'pro-monthly', // Optional. Your plan name.
142
- type: 'new', // Optional. 'new' | 'renewal' | 'upgrade' | 'downgrade'
65
+ IndieBob.init({
66
+ projectId: 'proj_…', // required
67
+ persistence: 'none', // 'none' (privacy mode) | 'local' (full attribution)
68
+ autoDetect: true, // automatic events, below; false turns them off
69
+ hashMode: false, // routes after a #
70
+ respectDnt: true, // send nothing when Do Not Track is on
71
+ respectGpc: true, // send nothing when Global Privacy Control is on
72
+ endpoint: 'https://www.indiebob.com/api/collect',
73
+ debug: false, // log every event to the console
143
74
  })
144
75
  ```
145
76
 
146
- ---
147
-
148
- ### Privacy Controls
149
-
150
- ```ts
151
- bob.optOut() // Stop all tracking. Clears all stored identifiers.
152
- bob.optIn() // Re-enable tracking.
153
- bob.isOptedOut() // Returns boolean.
154
- bob.isIgnored() // True if this browser excluded itself with ?ib_ignore=1.
155
- ```
156
-
157
- ### Exclude your own visits (`ib_ignore`)
77
+ A misspelled or unknown option logs a warning instead of being silently ignored.
158
78
 
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()`).
79
+ ## API
166
80
 
167
- Browsers driven by automation (`navigator.webdriver` — Playwright, Selenium,
168
- Puppeteer, headless test runners) send nothing at all.
169
-
170
- ---
171
-
172
- ### Debug / Testing
173
-
174
- ```ts
175
- bob.getAnonymousId() // Returns the current anonymous ID
176
- bob.getSessionId() // Returns the current session ID
177
- bob.getConfig() // Returns a read-only copy of the config
178
- bob.flush() // Force-send all queued events immediately
179
- ```
180
-
181
- ---
182
-
183
- ## Auto-Detection
184
-
185
- The SDK automatically fires semantic events out of the box — no manual `bob.track()` calls needed for common user journeys. This powers IndieBob's attribution funnels (visit → signup → checkout → paid).
186
-
187
- ### URL Intent Detection
188
-
189
- When `bob.page()` is called, the SDK checks the URL path against known patterns (multilingual: EN, ES, FR, DE, PT, IT, NL) and fires the corresponding event:
190
-
191
- | Event | Triggered By |
81
+ | Call | |
192
82
  |---|---|
193
- | `signup_start` | `/signup`, `/register`, `/join`, `/registro`, `/inscription`, `/anmeldung`, ... |
194
- | `login_view` | `/login`, `/sign-in`, `/iniciar-sesion`, `/connexion`, `/anmelden`, ... |
195
- | `pricing_view` | `/pricing`, `/plans`, `/precios`, `/tarifs`, `/preise`, ... |
196
- | `checkout_view` | `/checkout`, `/cart`, `/basket`, `/panier`, `/warenkorb`, `/kasse`, ... |
197
- | `success_view` | `/success`, `/thank-you`, `/confirmation`, `/danke`, `/merci`, ... |
198
- | `onboarding_view` | `/onboarding`, `/welcome`, `/getting-started`, `/bienvenido`, ... |
199
- | `contact_view` | `/contact`, `/contacto`, `/kontakt`, `/nous-contacter`, ... |
200
-
201
- ### Signup vs Login Detection
202
-
203
- When `bob.identify()` is called:
204
- - **First call** for this visitor fires `signup` (new user)
205
- - **Subsequent calls** fire `login` (returning user)
206
-
207
- This uses a localStorage flag — no server round-trip needed.
208
-
209
- ### Form Submit Detection
210
-
211
- On signup, login, and checkout pages, form submissions are automatically tracked:
212
- - `signup_submit` — form submitted on a signup page
213
- - `login_submit` — form submitted on a login page
214
- - `checkout_submit` — form submitted on a checkout page
215
-
216
- ### Outbound Click Tracking
217
-
218
- Clicks on links pointing to external domains automatically fire `outbound_click` with `{ url, hostname }` properties.
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
-
254
- ### Data Attribute Tracking
255
-
256
- Add `data-ib-event` to any element for zero-JS custom event tracking:
257
-
258
- ```html
259
- <button data-ib-event="add_to_cart" data-ib-plan="pro" data-ib-price="29">
260
- Add to Cart
261
- </button>
262
- ```
263
-
264
- This fires `track('add_to_cart', { plan: 'pro', price: '29' })` on click. All `data-ib-*` attributes (except `data-ib-event`) become event properties.
265
-
266
- ### Customizing Auto-Detection
83
+ | `IndieBob.init(config)` | Starts the tracker and returns it. Called again for the same project, it returns the running one. |
84
+ | `bob.page(overrides?)` | Counts a page view. The same path twice in a row counts once. `overrides`: `{ path, title, referrer }`. |
85
+ | `bob.track(name, properties?)` | A named event: `bob.track('csv_exported', { rows: 1240 })`. |
86
+ | `bob.identify(userId, traits?, { isNew? })` | After signup or login. Links the visit to your user id and sends `signup` or `login`. Pass `isNew: true` after a registration and `false` on a login — in privacy mode it is required for the `signup`/`login` event. Use your own id, never an email address, as `userId`. |
87
+ | `bob.revenue({ amount, currency, plan, type })` | A payment, if Stripe is not connected to IndieBob. `type`: `new`, `renewal`, `upgrade`, `downgrade`. |
88
+ | `bob.setPersistence('none' \| 'local')` | Switches mode, above. |
89
+ | `bob.enableAutoPage()` | Counts a page on every history change (the script tag does this itself). |
90
+ | `bob.optOut()`, `bob.optIn()`, `bob.isOptedOut()` | The visitor's own opt-out; `optOut()` removes everything stored. |
91
+ | `bob.isIgnored()` | Whether this browser is excluded with `?ib_ignore=1`. |
92
+ | `bob.flush()` | Sends queued events now. |
93
+
94
+ Events are sent in batches every 5 seconds and when the page is hidden or closed.
95
+
96
+ ## Collected automatically
97
+
98
+ | Event | When |
99
+ |---|---|
100
+ | `page` | Every page view, including a page restored with the Back button. |
101
+ | `engagement` | When a page is left or hidden: how long it was visible and focused, and how far it was scrolled. |
102
+ | `outbound_click` | A link to another site is clicked. |
103
+ | `file_download` | A link to a file (pdf, zip, dmg, exe, apk, …) is clicked. |
104
+ | `signup_start`, `login_view`, `pricing_view`, `checkout_view`, `success_view`, `onboarding_view`, `contact_view` | The page's path matches `/signup`, `/login`, `/pricing`, … or their Spanish, French, German, Portuguese, Italian or Dutch words. |
105
+ | `signup_submit`, `login_submit`, `checkout_submit` | A form is submitted on one of those pages. |
106
+ | any name | An element with `data-ib-event="name"` is clicked; its other `data-ib-*` attributes become properties. |
267
107
 
268
- Override the default URL patterns or disable specific features:
108
+ Turn parts off or replace the path patterns:
269
109
 
270
110
  ```ts
271
- const bob = IndieBob.init({
272
- projectId: 'proj_abc123',
273
- autoDetect: {
274
- // Custom URL patterns — strings or RegExp (override defaults)
275
- signupPaths: ['/my-signup-page'], // string: matched via path.includes()
276
- checkoutPaths: [/\/buy|\/purchase/i], // RegExp: matched via .test()
277
-
278
- // Disable specific features
279
- outboundClicks: false,
280
- fileDownloads: false,
281
- formSubmit: false,
282
- dataAttributes: false,
283
- },
284
- })
111
+ autoDetect: { fileDownloads: false, outboundClicks: false, formSubmit: false, dataAttributes: false,
112
+ signupPaths: [/\/start-trial/] }
285
113
  ```
286
114
 
287
- Set `autoDetect: false` to disable all auto-detection entirely.
288
-
289
- | Option | Type | Default | Description |
290
- |---|---|---|---|
291
- | `signupPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for signup pages |
292
- | `loginPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for login pages |
293
- | `pricingPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for pricing pages |
294
- | `checkoutPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for checkout/cart pages |
295
- | `successPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for success/thank-you pages |
296
- | `onboardingPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for onboarding pages |
297
- | `contactPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for contact pages |
298
- | `dataAttributes` | `boolean` | `true` | Track clicks on `data-ib-event` elements |
299
- | `formSubmit` | `boolean` | `true` | Track form submissions on intent pages |
300
- | `outboundClicks` | `boolean` | `true` | Track clicks on external links |
301
- | `fileDownloads` | `boolean` | `true` | Track clicks on links to files as `file_download` |
302
-
303
- ---
304
-
305
- ## Framework Integrations
306
-
307
- ### Vue / Nuxt
308
-
309
- ```ts
310
- // main.ts or plugins/tracker.client.ts
311
- import { IndieBobPlugin } from '@indiebob/tracker/vue'
312
-
313
- app.use(IndieBobPlugin, { projectId: 'proj_abc123def456ghij' })
314
- ```
115
+ ## What is sent
315
116
 
316
- Then in any component:
117
+ - The page's path and URL, keeping only the `utm_*` and `ref` query parameters. Anything else in a
118
+ query string — sign-in tokens, reset codes, email addresses, ad click ids — is removed before it
119
+ leaves the page, and IndieBob's server removes it again from older versions of the tracker.
120
+ - The page title; the referrer's origin and path; the screen size class; the browser's language and
121
+ time zone; your event's properties.
122
+ - IndieBob adds the country, region and city (looked up from the IP address, which is not stored),
123
+ the browser, the operating system and the device type.
317
124
 
318
- ```ts
319
- import { useTracker } from '@indiebob/tracker/vue'
125
+ Nothing is sent from a browser with Do Not Track or Global Privacy Control on, after `optOut()`,
126
+ from automated browsers, or from your own browser once you open your site with `?ib_ignore=1`
127
+ (`?ib_ignore=0` undoes it).
320
128
 
321
- const bob = useTracker()
322
- bob.track('button_clicked', { button: 'cta_hero' })
323
- ```
129
+ IndieBob processes this data for you, in the EU (Frankfurt). A data processing agreement is
130
+ available from info@indiebob.com.
324
131
 
325
- **Nuxt plugin** (`plugins/tracker.client.ts`):
132
+ ## Vue and Nuxt
326
133
 
327
134
  ```ts
135
+ // plugins/indiebob.client.ts (Nuxt) — or app.use(...) after the router in a Vue app
328
136
  import { IndieBobPlugin } from '@indiebob/tracker/vue'
329
137
 
330
138
  export default defineNuxtPlugin((nuxtApp) => {
331
- nuxtApp.vueApp.use(IndieBobPlugin, {
332
- projectId: useRuntimeConfig().public.trackerProjectId,
333
- })
139
+ nuxtApp.vueApp.use(IndieBobPlugin, { projectId: 'proj_…' })
334
140
  })
335
141
  ```
336
142
 
337
- Page views auto-track on vue-router navigation.
338
-
339
- ### React
143
+ Page views follow vue-router. In a component: `const bob = useTracker()` from
144
+ `@indiebob/tracker/vue`.
340
145
 
341
- ```tsx
342
- import { IndieBob } from '@indiebob/tracker'
343
- import { useEffect } from 'react'
146
+ ## React
344
147
 
345
- // Initialize once at app root
346
- const bob = IndieBob.init({ projectId: 'proj_abc123def456ghij' })
148
+ There is no React package; call `page()` when the route changes:
347
149
 
348
- // In a page component or layout
349
- useEffect(() => {
350
- bob.page()
351
- }, [pathname])
352
- ```
353
-
354
- ### Vanilla JS (Script Tag)
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
-
387
- ```html
388
- <script src="https://assets.indiebob.com/tracker/latest/indiebob.min.js"></script>
389
- <script>
390
- const bob = IndieBob.init({ projectId: 'proj_abc123def456ghij' })
391
- bob.page()
392
- </script>
150
+ ```tsx
151
+ const pathname = usePathname()
152
+ useEffect(() => { bob.page() }, [pathname])
393
153
  ```
394
154
 
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.
155
+ ## Server
399
156
 
400
- ### Server-Side (Node.js)
157
+ For events with no browser — a webhook, a job:
401
158
 
402
159
  ```ts
403
160
  import { IndieBob } from '@indiebob/tracker/server'
404
161
 
405
- const bob = IndieBob.initServer({ projectId: 'proj_abc123def456ghij' })
406
- bob.track('server_event', { source: 'cron_job' })
162
+ const bob = IndieBob.initServer({ projectId: 'proj_…' })
163
+ await bob.track('trial_started', { plan: 'pro' }, 'user_123')
164
+ await bob.identify('user_123', { plan: 'pro' })
407
165
  ```
408
166
 
409
- ---
410
-
411
- ## How It Works
412
-
413
- ### Anonymous ID
414
-
415
- A random 21-character ID is generated on first visit and stored in `localStorage`. This persists across sessions so you can track a visitor's journey before they sign up.
416
-
417
- - Stored as `_ib_anon` in localStorage
418
- - Falls back to sessionStorage if localStorage is blocked
419
- - Never uses cookies — no consent banner required
420
- - Cleared when `optOut()` is called
421
-
422
- ### Session ID
423
-
424
- A session ID is generated per browser tab/window using `crypto.randomUUID()`. Sessions expire after 30 minutes of inactivity or when the tab closes.
425
-
426
- ### UTM Attribution
427
-
428
- UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`) are captured from the URL automatically on `init()`.
429
-
430
- - **First-touch** attribution stored in `_ib_first_utm` — never overwritten
431
- - **Last-touch** attribution in `_ib_last_utm` — updated each time UTMs are present in the URL
432
- - Enables "which campaign drove this paying customer?" — the core attribution question
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
-
440
- ### Event Batching
441
-
442
- Events are batched for efficiency:
443
- - Sent when 10 events accumulate
444
- - Sent every 5 seconds regardless
445
- - Sent immediately when the page becomes hidden (tab switch, close)
446
- - Queued in localStorage when offline; flushed when back online
447
-
448
- ### Privacy by Default
449
-
450
- The SDK respects:
451
- - **Do Not Track** (`navigator.doNotTrack === '1'`): no events sent, `respectDnt: true` by default
452
- - **Global Privacy Control** (`navigator.globalPrivacyControl`): legally binding under CCPA in California, `respectGpc: true` by default
453
- - Neither cookies nor fingerprinting is used at any point
454
- - IP addresses are never stored by the server — only the country code derived at ingestion time
455
-
456
- ---
457
-
458
- ## What Is and Isn't Collected
459
-
460
- ### Automatically collected (on `page()`)
461
-
462
- | Data | What | Stored As |
463
- |---|---|---|
464
- | Page path | `/pricing`, `/blog/post-1` | String |
465
- | Page title | `document.title` | String |
466
- | Referrer | `document.referrer` | String |
467
- | UTM params | From URL query string | Object |
468
- | Screen size | `window.innerWidth/Height` | Numbers |
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 |
472
- | Browser locale | `navigator.language` | String |
473
- | Timezone | `Intl.DateTimeFormat().resolvedOptions().timeZone` | String |
474
- | Country | Derived server-side from IP | String (e.g. `'US'`) |
475
-
476
- ### Auto-detected events (when `autoDetect` is enabled)
477
-
478
- | Event | Trigger |
479
- |---|---|
480
- | `signup_start` | User visits a signup/register page |
481
- | `signup_submit` | User submits a form on a signup page |
482
- | `signup` | First `identify()` call (new user) |
483
- | `login_view` | User visits a login page |
484
- | `login_submit` | User submits a form on a login page |
485
- | `login` | Subsequent `identify()` call (returning user) |
486
- | `pricing_view` | User visits a pricing page |
487
- | `checkout_view` | User visits a checkout/cart page |
488
- | `checkout_submit` | User submits a form on a checkout page |
489
- | `success_view` | User visits a success/thank-you page |
490
- | `onboarding_view` | User visits an onboarding page |
491
- | `contact_view` | User visits a contact page |
492
- | `outbound_click` | User clicks an external link |
493
- | `file_download` | User clicks a link to a file (pdf, zip, dmg, …) |
494
- | Custom via `data-ib-event` | User clicks an element with the attribute |
495
-
496
- ### NOT collected
497
-
498
- - IP addresses (never stored, used only for country lookup then discarded)
499
- - Browser fingerprint
500
- - Cookies
501
- - Personal data (name, email, etc.) unless you pass it to `identify()`
502
- - Cross-site tracking data
503
-
504
- ---
505
-
506
- ## Privacy & Compliance
507
-
508
- ### GDPR (EU)
509
-
510
- The SDK does not use cookies and does not collect personal data by default. The `anonymousId` is a random identifier with no link to personal identity. Under GDPR, anonymous analytics may not require a consent banner — but consult your legal team.
511
-
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`.
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
-
517
- ### CCPA (California)
518
-
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.
520
-
521
- ### Cookie Law (EU ePrivacy Directive)
522
-
523
- No cookies are set. The localStorage identifiers are not cookies and fall outside the scope of the Cookie Law.
524
-
525
- ---
526
-
527
- ## Self-Hosting
528
-
529
- Point the SDK at your own IndieBob instance:
530
-
531
- ```ts
532
- const bob = IndieBob.init({
533
- projectId: 'proj_abc123def456ghij',
534
- endpoint: 'https://your-indiebob.example.com/api/collect',
535
- })
536
- ```
537
-
538
- ---
539
-
540
- ## Versioning & Backwards Compatibility
541
-
542
- This SDK follows [Semantic Versioning](https://semver.org/).
543
-
544
- **1.x guarantee**: All public methods listed in this README are stable. We will never:
545
- - Remove a method in a 1.x release
546
- - Change the behavior of an existing method without a deprecation period
547
- - Break the wire format for events that include `v: 1`
548
-
549
- **Upgrade path**: `npm update @indiebob/tracker` — no code changes required within 1.x.
550
-
551
- **Deprecation process**: Methods marked `@deprecated` in JSDoc will log a warning in `debug: true` mode. They remain functional until the next major version.
552
-
553
- ---
554
-
555
- ## Bundle Size
556
-
557
- | Bundle | Size (gzip -9) |
558
- |---|---|
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) |
561
- | Server (Node.js) | No limit |
562
-
563
- Zero runtime dependencies.
564
-
565
- ## Browser Support
167
+ ## Versions
566
168
 
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.
169
+ `/tracker/latest/indiebob.min.js` is always the newest release. To stay on one version, load
170
+ `/tracker/v0.4.1/indiebob.min.js`; a versioned file never changes. The npm package follows
171
+ [semver](https://semver.org/); while it is 0.x, a minor release (0.3 → 0.4) can change behaviour, so
172
+ read [CHANGELOG.md](./CHANGELOG.md) before upgrading.
570
173
 
571
- ---
174
+ Browsers: the script tag needs Chrome 80, Firefox 74 or Safari 13.1 or newer; the npm build targets
175
+ ES2018.
572
176
 
573
177
  ## License
574
178