@indiebob/tracker 0.2.4 → 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/CHANGELOG.md CHANGED
@@ -6,6 +6,95 @@ This project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.4.1] — 2026-11-02
10
+
11
+ - Shorter README. No code changes.
12
+
13
+ ## [0.4.0] — 2026-11-02
14
+
15
+ Privacy mode is the default: the tracker stores nothing in the visitor's browser, and IndieBob's
16
+ server identifies a visitor for one day from a hash of a daily salt, the project, the IP address and
17
+ the user agent. The 0.3 behaviour is `persistence: 'local'` (needs consent in the EU).
18
+
19
+ Needs IndieBob's `/api/collect` from 2026-11-02 or later; an older server rejects events without
20
+ `anonymousId`.
21
+
22
+ ### Changed
23
+ - Without `persistence: 'local'`, a visitor who comes back on another day is a new visitor, and a
24
+ signup is not credited to a visit on an earlier day.
25
+ - In privacy mode `identify()` sends `signup` / `login` only when you pass `{ isNew }`.
26
+ - `getAnonymousId()` and `getSessionId()` return `''` in privacy mode.
27
+ - Page paths and URLs keep only the `utm_*` and `ref` query parameters; referrers keep origin and
28
+ path. A navigation that changes only another query parameter is no longer a new page.
29
+
30
+ ### Added
31
+ - `persistence: 'none' | 'local'`, `data-persistence="local"`, and `bob.setPersistence()` for consent
32
+ banners (`'none'` removes everything the tracker stored).
33
+ - A warning for unknown or misspelled options.
34
+
35
+ ### Fixed
36
+ - Nothing is written to storage before Do Not Track, Global Privacy Control and opt-out are checked.
37
+
38
+ ### Size
39
+ - Script tag 5,699 bytes gzipped (0.3.0: 5,076).
40
+
41
+ ## [0.3.0] — 2026-11-01
42
+
43
+ The npm module bundles (`index.js`/`.cjs`, `vue.js`/`.cjs`) are no longer
44
+ minified — your bundler minifies them. A minified bundle's comma-joined
45
+ declarations slipped past Nuxt's auto-import scanner and broke a Nuxt build.
46
+ The script-tag bundle (`indiebob.min.js`) stays minified, under 5 KB.
47
+
48
+ Needs the matching `/api/collect` (it must accept `type: 'engagement'` and
49
+ the new top-level `id` and `engagement` fields) **before** this version is
50
+ served: the 0.2 server rejects a whole batch that contains an engagement event.
51
+
52
+ ### Added
53
+ - **Self-initialising script tag.** `<script src=".../indiebob.min.js"
54
+ data-project="proj_…" defer></script>` initialises itself and tracks the
55
+ page plus every SPA navigation, with no `init()` / `page()` call. Optional
56
+ `data-api` (endpoint), `data-auto-page="false"` (initialise only),
57
+ `data-hash-mode="true"`. A tag without `data-project` behaves exactly as
58
+ before.
59
+ - **Event id.** Every event carries a top-level `id` (21 url-safe
60
+ characters), unchanged across retries and offline replays, so the server
61
+ can store each event once. Server-side events (`/server`) carry one too.
62
+ - **`engagement` event.** Visible-and-focused milliseconds and the deepest
63
+ scroll depth (% of the page) for the page it describes, with that page's
64
+ context. Sent on tab hide, pagehide and right before an SPA navigation,
65
+ only when the scroll grew or ≥ 3 s accumulated since the last one; never a
66
+ pageview.
67
+ - **Automatic file downloads.** A click on a link to a file (pdf, zip, dmg,
68
+ exe, msi, pkg, apk, ipa, deb, rpm, appimage, archives, office files, txt,
69
+ epub, mp3/wav/mp4/mov, iso) fires `file_download` with
70
+ `{ url, path, ext, auto: true }`, on any host; `url` drops the query and
71
+ hash. An outbound download fires only `file_download`. Off with
72
+ `autoDetect: { fileDownloads: false }`.
73
+ - **Owner self-exclude.** `?ib_ignore=1` stops this browser being counted
74
+ (stored as `localStorage.ib_ignore`, with one console line per page load);
75
+ `?ib_ignore=0` undoes it. `bob.isIgnored()` reports it.
76
+ - **`hashMode`** option for `/#/route` routers.
77
+
78
+ ### Changed
79
+ - A `page()` for the path already counted (same pathname + query; a
80
+ hash-only change unless `hashMode`) is skipped.
81
+ - A back/forward-cache restore (`pageshow` with `persisted`) counts as a
82
+ pageview.
83
+ - A page that is prerendered or opened in a background tab counts its first
84
+ pageview when it is first shown.
85
+ - `navigator.webdriver` browsers send nothing.
86
+ - `init()` with a `projectId` that is already running returns that instance
87
+ (the first config wins) instead of starting a second tracker.
88
+ - Builds: the script-tag bundle targets ES2020 (Chrome 80, Firefox 74,
89
+ Safari 13.1) and the npm ESM/CJS build ES2018 (was ES2015 for both) — the
90
+ price of staying under 5 KB gzipped with the features above. Private
91
+ members are shortened at build time.
92
+ - The plain-http fallback for the session id (no `crypto.randomUUID` outside
93
+ a secure context) now uses the SDK's crypto id generator instead of
94
+ `Math.random`.
95
+
96
+ ---
97
+
9
98
  ## [0.2.4] — 2026-09-08
10
99
 
11
100
  ### Added
package/README.md CHANGED
@@ -1,455 +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):
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"></script>
17
- ```
18
-
19
- ## Quick Start
20
-
21
- Three lines to start tracking:
22
-
23
- ```js
24
- import { IndieBob } from '@indiebob/tracker'
25
-
26
- const bob = IndieBob.init({ projectId: 'proj_your16charId' })
27
- bob.page()
28
- ```
29
-
30
- That's it. Page views are now tracked. UTM parameters are auto-captured.
31
-
32
- ## Configuration
33
-
34
- ```ts
35
- const bob = IndieBob.init({
36
- projectId: 'proj_your16charId', // Required. From your IndieBob dashboard.
37
- endpoint: 'https://indiebob.com/api/collect', // Optional. Default shown.
38
- debug: false, // Optional. Logs events to console when true.
39
- respectDnt: true, // Optional. Honor browser Do Not Track. Default: true.
40
- respectGpc: true, // Optional. Honor Global Privacy Control. Default: true.
41
- autoDetect: true, // Optional. Auto-detect signups, logins, checkouts, etc. Default: true.
42
- })
13
+ <script src="https://assets.indiebob.com/tracker/latest/indiebob.min.js" data-project="proj_…" defer></script>
43
14
  ```
44
15
 
45
- | Option | Type | Default | Description |
46
- |---|---|---|---|
47
- | `projectId` | `string` | **required** | Your SDK project ID from the dashboard |
48
- | `endpoint` | `string` | `'https://indiebob.com/api/collect'` | Override for self-hosted instances |
49
- | `debug` | `boolean` | `false` | Log all events to browser console |
50
- | `respectDnt` | `boolean` | `true` | Honor the browser's Do Not Track header |
51
- | `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). |
53
-
54
- ## API Reference
55
-
56
- ### `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. |
57
23
 
58
- Creates and returns a tracker instance. Call once at app startup.
24
+ **npm**
59
25
 
60
- ```ts
61
- const bob = IndieBob.init({ projectId: 'proj_abc123def456ghij' })
26
+ ```sh
27
+ npm install @indiebob/tracker
62
28
  ```
63
29
 
64
- ---
65
-
66
- ### `bob.page(overrides?)`
67
-
68
- Track a page view. Call this on every page load and SPA navigation.
69
-
70
30
  ```ts
71
- bob.page() // Auto-detects path, title, referrer
72
- bob.page({ path: '/pricing' }) // Override path
73
- bob.page({ title: 'Pricing Page' }) // Override title
74
- ```
75
-
76
- **Auto-detected:** URL path, page title, referrer, UTM parameters, device type.
77
-
78
- ---
79
-
80
- ### `bob.track(event, properties?)`
81
-
82
- Track a custom event.
31
+ import { IndieBob } from '@indiebob/tracker'
83
32
 
84
- ```ts
85
- bob.track('signup_started')
86
- bob.track('trial_activated', { plan: 'pro' })
87
- bob.track('feature_used', { feature: 'csv_export', count: 3 })
88
- bob.track('checkout_completed', { revenue: 29.00, currency: 'USD', plan: 'pro-monthly' })
33
+ const bob = IndieBob.init({ projectId: 'proj_…' })
34
+ bob.page()
89
35
  ```
90
36
 
91
- **`event`** — string, required. Max 256 characters. Use `snake_case` by convention.
92
-
93
- **`properties`** — plain object, optional. Values must be JSON-serializable.
37
+ ## Privacy mode and full attribution
94
38
 
95
- ---
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 |
96
45
 
97
- ### `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.
98
49
 
99
- 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:
100
53
 
101
54
  ```ts
102
- bob.identify('user_123', {
103
- email: 'jane@example.com',
104
- name: 'Jane Smith',
105
- plan: 'pro',
106
- createdAt: '2026-01-15',
107
- })
55
+ bob.setPersistence('local') // the visitor accepted
56
+ bob.setPersistence('none') // consent withdrawn: removes everything the tracker stored
108
57
  ```
109
58
 
110
- **`userId`** — string, required. Your internal user ID. Never pass PII as the userId directly.
111
-
112
- **`traits`** — plain object, optional. Can include `email` for attribution linking.
113
-
114
- After `identify()`, all subsequent events are linked to this userId until the session ends.
115
-
116
- ---
59
+ If consent is already known when the page loads, pass `persistence: 'local'` to `init()` or put
60
+ `data-persistence="local"` on the tag.
117
61
 
118
- ### `bob.revenue(data)`
119
-
120
- Track a revenue event. Use alongside Stripe webhooks for complete attribution.
62
+ ## Configuration
121
63
 
122
64
  ```ts
123
- bob.revenue({
124
- amount: 29.00, // Required. Positive number.
125
- currency: 'USD', // Optional. Default: 'USD'. ISO 4217 code.
126
- plan: 'pro-monthly', // Optional. Your plan name.
127
- 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
128
74
  })
129
75
  ```
130
76
 
131
- ---
132
-
133
- ### Privacy Controls
134
-
135
- ```ts
136
- bob.optOut() // Stop all tracking. Clears all stored identifiers.
137
- bob.optIn() // Re-enable tracking.
138
- bob.isOptedOut() // Returns boolean.
139
- ```
140
-
141
- ---
142
-
143
- ### Debug / Testing
144
-
145
- ```ts
146
- bob.getAnonymousId() // Returns the current anonymous ID
147
- bob.getSessionId() // Returns the current session ID
148
- bob.getConfig() // Returns a read-only copy of the config
149
- bob.flush() // Force-send all queued events immediately
150
- ```
151
-
152
- ---
77
+ A misspelled or unknown option logs a warning instead of being silently ignored.
153
78
 
154
- ## Auto-Detection
79
+ ## API
155
80
 
156
- 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).
157
-
158
- ### URL Intent Detection
159
-
160
- 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:
161
-
162
- | Event | Triggered By |
81
+ | Call | |
163
82
  |---|---|
164
- | `signup_start` | `/signup`, `/register`, `/join`, `/registro`, `/inscription`, `/anmeldung`, ... |
165
- | `login_view` | `/login`, `/sign-in`, `/iniciar-sesion`, `/connexion`, `/anmelden`, ... |
166
- | `pricing_view` | `/pricing`, `/plans`, `/precios`, `/tarifs`, `/preise`, ... |
167
- | `checkout_view` | `/checkout`, `/cart`, `/basket`, `/panier`, `/warenkorb`, `/kasse`, ... |
168
- | `success_view` | `/success`, `/thank-you`, `/confirmation`, `/danke`, `/merci`, ... |
169
- | `onboarding_view` | `/onboarding`, `/welcome`, `/getting-started`, `/bienvenido`, ... |
170
- | `contact_view` | `/contact`, `/contacto`, `/kontakt`, `/nous-contacter`, ... |
171
-
172
- ### Signup vs Login Detection
173
-
174
- When `bob.identify()` is called:
175
- - **First call** for this visitor fires `signup` (new user)
176
- - **Subsequent calls** fire `login` (returning user)
177
-
178
- This uses a localStorage flag — no server round-trip needed.
179
-
180
- ### Form Submit Detection
181
-
182
- On signup, login, and checkout pages, form submissions are automatically tracked:
183
- - `signup_submit` — form submitted on a signup page
184
- - `login_submit` — form submitted on a login page
185
- - `checkout_submit` — form submitted on a checkout page
186
-
187
- ### Outbound Click Tracking
188
-
189
- Clicks on links pointing to external domains automatically fire `outbound_click` with `{ url, hostname }` properties.
190
-
191
- ### Data Attribute Tracking
192
-
193
- Add `data-ib-event` to any element for zero-JS custom event tracking:
194
-
195
- ```html
196
- <button data-ib-event="add_to_cart" data-ib-plan="pro" data-ib-price="29">
197
- Add to Cart
198
- </button>
199
- ```
200
-
201
- This fires `track('add_to_cart', { plan: 'pro', price: '29' })` on click. All `data-ib-*` attributes (except `data-ib-event`) become event properties.
202
-
203
- ### 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. |
204
107
 
205
- Override the default URL patterns or disable specific features:
108
+ Turn parts off or replace the path patterns:
206
109
 
207
110
  ```ts
208
- const bob = IndieBob.init({
209
- projectId: 'proj_abc123',
210
- autoDetect: {
211
- // Custom URL patterns — strings or RegExp (override defaults)
212
- signupPaths: ['/my-signup-page'], // string: matched via path.includes()
213
- checkoutPaths: [/\/buy|\/purchase/i], // RegExp: matched via .test()
214
-
215
- // Disable specific features
216
- outboundClicks: false,
217
- formSubmit: false,
218
- dataAttributes: false,
219
- },
220
- })
111
+ autoDetect: { fileDownloads: false, outboundClicks: false, formSubmit: false, dataAttributes: false,
112
+ signupPaths: [/\/start-trial/] }
221
113
  ```
222
114
 
223
- Set `autoDetect: false` to disable all auto-detection entirely.
115
+ ## What is sent
224
116
 
225
- | Option | Type | Default | Description |
226
- |---|---|---|---|
227
- | `signupPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for signup pages |
228
- | `loginPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for login pages |
229
- | `pricingPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for pricing pages |
230
- | `checkoutPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for checkout/cart pages |
231
- | `successPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for success/thank-you pages |
232
- | `onboardingPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for onboarding pages |
233
- | `contactPaths` | `(string \| RegExp)[]` | built-in i18n | URL patterns for contact pages |
234
- | `dataAttributes` | `boolean` | `true` | Track clicks on `data-ib-event` elements |
235
- | `formSubmit` | `boolean` | `true` | Track form submissions on intent pages |
236
- | `outboundClicks` | `boolean` | `true` | Track clicks on external links |
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.
237
124
 
238
- ---
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).
239
128
 
240
- ## Framework Integrations
129
+ IndieBob processes this data for you, in the EU (Frankfurt). A data processing agreement is
130
+ available from info@indiebob.com.
241
131
 
242
- ### Vue / Nuxt
243
-
244
- ```ts
245
- // main.ts or plugins/tracker.client.ts
246
- import { IndieBobPlugin } from '@indiebob/tracker/vue'
247
-
248
- app.use(IndieBobPlugin, { projectId: 'proj_abc123def456ghij' })
249
- ```
250
-
251
- Then in any component:
252
-
253
- ```ts
254
- import { useTracker } from '@indiebob/tracker/vue'
255
-
256
- const bob = useTracker()
257
- bob.track('button_clicked', { button: 'cta_hero' })
258
- ```
259
-
260
- **Nuxt plugin** (`plugins/tracker.client.ts`):
132
+ ## Vue and Nuxt
261
133
 
262
134
  ```ts
135
+ // plugins/indiebob.client.ts (Nuxt) — or app.use(...) after the router in a Vue app
263
136
  import { IndieBobPlugin } from '@indiebob/tracker/vue'
264
137
 
265
138
  export default defineNuxtPlugin((nuxtApp) => {
266
- nuxtApp.vueApp.use(IndieBobPlugin, {
267
- projectId: useRuntimeConfig().public.trackerProjectId,
268
- })
139
+ nuxtApp.vueApp.use(IndieBobPlugin, { projectId: 'proj_…' })
269
140
  })
270
141
  ```
271
142
 
272
- Page views auto-track on vue-router navigation.
143
+ Page views follow vue-router. In a component: `const bob = useTracker()` from
144
+ `@indiebob/tracker/vue`.
273
145
 
274
- ### React
146
+ ## React
275
147
 
276
- ```tsx
277
- import { IndieBob } from '@indiebob/tracker'
278
- import { useEffect } from 'react'
148
+ There is no React package; call `page()` when the route changes:
279
149
 
280
- // Initialize once at app root
281
- const bob = IndieBob.init({ projectId: 'proj_abc123def456ghij' })
282
-
283
- // In a page component or layout
284
- useEffect(() => {
285
- bob.page()
286
- }, [pathname])
150
+ ```tsx
151
+ const pathname = usePathname()
152
+ useEffect(() => { bob.page() }, [pathname])
287
153
  ```
288
154
 
289
- ### Vanilla JS (Script Tag)
155
+ ## Server
290
156
 
291
- ```html
292
- <script src="https://assets.indiebob.com/tracker/latest/indiebob.min.js"></script>
293
- <script>
294
- const bob = IndieBob.init({ projectId: 'proj_abc123def456ghij' })
295
- bob.page()
296
- </script>
297
- ```
298
-
299
- ### Server-Side (Node.js)
157
+ For events with no browser — a webhook, a job:
300
158
 
301
159
  ```ts
302
160
  import { IndieBob } from '@indiebob/tracker/server'
303
161
 
304
- const bob = IndieBob.initServer({ projectId: 'proj_abc123def456ghij' })
305
- 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' })
306
165
  ```
307
166
 
308
- ---
309
-
310
- ## How It Works
311
-
312
- ### Anonymous ID
313
-
314
- 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.
315
-
316
- - Stored as `_ib_anon` in localStorage
317
- - Falls back to sessionStorage if localStorage is blocked
318
- - Never uses cookies — no consent banner required
319
- - Cleared when `optOut()` is called
320
-
321
- ### Session ID
322
-
323
- A session ID is generated per browser tab/window using `crypto.randomUUID()`. Sessions expire after 30 minutes of inactivity or when the tab closes.
324
-
325
- ### UTM Attribution
326
-
327
- UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`) are captured from the URL automatically on `init()`.
328
-
329
- - **First-touch** attribution stored in `_ib_first_utm` — never overwritten
330
- - **Last-touch** attribution in `_ib_last_utm` — updated each time UTMs are present in the URL
331
- - Enables "which campaign drove this paying customer?" — the core attribution question
332
-
333
- ### Event Batching
334
-
335
- Events are batched for efficiency:
336
- - Sent when 10 events accumulate
337
- - Sent every 5 seconds regardless
338
- - Sent immediately when the page becomes hidden (tab switch, close)
339
- - Queued in localStorage when offline; flushed when back online
340
-
341
- ### Privacy by Default
342
-
343
- The SDK respects:
344
- - **Do Not Track** (`navigator.doNotTrack === '1'`): no events sent, `respectDnt: true` by default
345
- - **Global Privacy Control** (`navigator.globalPrivacyControl`): legally binding under CCPA in California, `respectGpc: true` by default
346
- - Neither cookies nor fingerprinting is used at any point
347
- - IP addresses are never stored by the server — only the country code derived at ingestion time
348
-
349
- ---
350
-
351
- ## What Is and Isn't Collected
352
-
353
- ### Automatically collected (on `page()`)
354
-
355
- | Data | What | Stored As |
356
- |---|---|---|
357
- | Page path | `/pricing`, `/blog/post-1` | String |
358
- | Page title | `document.title` | String |
359
- | Referrer | `document.referrer` | String |
360
- | UTM params | From URL query string | Object |
361
- | Screen size | `window.innerWidth/Height` | Numbers |
362
- | Device type | Derived from screen size | `'desktop'` \| `'mobile'` \| `'tablet'` |
363
- | Browser locale | `navigator.language` | String |
364
- | Timezone | `Intl.DateTimeFormat().resolvedOptions().timeZone` | String |
365
- | Country | Derived server-side from IP | String (e.g. `'US'`) |
366
-
367
- ### Auto-detected events (when `autoDetect` is enabled)
368
-
369
- | Event | Trigger |
370
- |---|---|
371
- | `signup_start` | User visits a signup/register page |
372
- | `signup_submit` | User submits a form on a signup page |
373
- | `signup` | First `identify()` call (new user) |
374
- | `login_view` | User visits a login page |
375
- | `login_submit` | User submits a form on a login page |
376
- | `login` | Subsequent `identify()` call (returning user) |
377
- | `pricing_view` | User visits a pricing page |
378
- | `checkout_view` | User visits a checkout/cart page |
379
- | `checkout_submit` | User submits a form on a checkout page |
380
- | `success_view` | User visits a success/thank-you page |
381
- | `onboarding_view` | User visits an onboarding page |
382
- | `contact_view` | User visits a contact page |
383
- | `outbound_click` | User clicks an external link |
384
- | Custom via `data-ib-event` | User clicks an element with the attribute |
385
-
386
- ### NOT collected
387
-
388
- - IP addresses (never stored, used only for country lookup then discarded)
389
- - Browser fingerprint
390
- - Cookies
391
- - Personal data (name, email, etc.) unless you pass it to `identify()`
392
- - Cross-site tracking data
393
-
394
- ---
395
-
396
- ## Privacy & Compliance
397
-
398
- ### GDPR (EU)
399
-
400
- 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.
401
-
402
- 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
-
404
- ### CCPA (California)
405
-
406
- 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.
407
-
408
- ### Cookie Law (EU ePrivacy Directive)
409
-
410
- No cookies are set. The localStorage identifiers are not cookies and fall outside the scope of the Cookie Law.
411
-
412
- ---
413
-
414
- ## Self-Hosting
415
-
416
- Point the SDK at your own IndieBob instance:
417
-
418
- ```ts
419
- const bob = IndieBob.init({
420
- projectId: 'proj_abc123def456ghij',
421
- endpoint: 'https://your-indiebob.example.com/api/collect',
422
- })
423
- ```
424
-
425
- ---
426
-
427
- ## Versioning & Backwards Compatibility
428
-
429
- This SDK follows [Semantic Versioning](https://semver.org/).
430
-
431
- **1.x guarantee**: All public methods listed in this README are stable. We will never:
432
- - Remove a method in a 1.x release
433
- - Change the behavior of an existing method without a deprecation period
434
- - Break the wire format for events that include `v: 1`
435
-
436
- **Upgrade path**: `npm update @indiebob/tracker` — no code changes required within 1.x.
437
-
438
- **Deprecation process**: Methods marked `@deprecated` in JSDoc will log a warning in `debug: true` mode. They remain functional until the next major version.
439
-
440
- ---
441
-
442
- ## Bundle Size
443
-
444
- | Bundle | Size (gzip) |
445
- |---|---|
446
- | Browser IIFE (`tracker.js`) | < 5KB |
447
- | ESM tree-shakeable | < 4KB |
448
- | Server (Node.js) | No limit |
167
+ ## Versions
449
168
 
450
- Zero runtime dependencies.
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.
451
173
 
452
- ---
174
+ Browsers: the script tag needs Chrome 80, Firefox 74 or Safari 13.1 or newer; the npm build targets
175
+ ES2018.
453
176
 
454
177
  ## License
455
178