@pl4yzonellc/empire-analytics 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,744 @@
1
+ # `@pl4yzonellc/empire-analytics`
2
+
3
+ A **provider-agnostic analytics SDK** for React 19 applications. It gives product
4
+ teams one small, strongly typed API for tracking business events, custom
5
+ properties and revenue — and keeps the analytics vendor an implementation detail.
6
+
7
+ Plausible is the first provider. **Application code never imports Plausible APIs
8
+ and never touches `window.plausible`.**
9
+
10
+ - [What it does](#what-it-does)
11
+ - [Architecture](#architecture)
12
+ - [Installation](#installation)
13
+ - [Basic React setup](#basic-react-setup)
14
+ - [Non-React / global usage](#non-react--global-usage)
15
+ - [Configuration](#configuration)
16
+ - [Environment behavior](#environment-behavior)
17
+ - [Built-in event catalog](#built-in-event-catalog)
18
+ - [Application-specific events](#application-specific-events)
19
+ - [Event properties](#event-properties)
20
+ - [Revenue events](#revenue-events)
21
+ - [Privacy protection](#privacy-protection)
22
+ - [Debugging](#debugging)
23
+ - [Explicit page views](#explicit-page-views)
24
+ - [Multi-tenant configuration](#multi-tenant-configuration)
25
+ - [Testing consuming applications](#testing-consuming-applications)
26
+ - [How the Plausible adapter works](#how-the-plausible-adapter-works)
27
+ - [Adding a future provider](#adding-a-future-provider)
28
+ - [Package scripts](#package-scripts)
29
+
30
+ ---
31
+
32
+ ## What it does
33
+
34
+ A React application that installs this package can:
35
+
36
+ - initialize analytics once, near the app root
37
+ - automatically load and configure the analytics provider
38
+ - track **strongly typed** business events and custom properties
39
+ - track revenue / conversion events through a first-class abstraction
40
+ - optionally send explicit page views
41
+ - use React hooks (`useAnalytics`, `useTrackEvent`) and an optional `<Track>` component
42
+ - prevent sensitive data (passwords, tokens, card numbers, PII…) from being tracked
43
+ - disable analytics per environment and turn on a debug mode
44
+ - swap the analytics provider later without touching application code
45
+ - run cleanly in multi-tenant apps
46
+ - be tested without loading a real analytics vendor
47
+
48
+ It deliberately does **not** do dashboard reporting, Stats-API querying, or
49
+ anything that needs a secret API key. This package only _sends_ client-side
50
+ analytics.
51
+
52
+ ## Architecture
53
+
54
+ ```text
55
+ React application
56
+
57
+
58
+ React bindings src/react <AnalyticsProvider>, useAnalytics, useTrackEvent, <Track>
59
+
60
+
61
+ Analytics Core src/core AnalyticsClient — enabled state, config, debug, lifecycle
62
+
63
+ ├─ Event catalog src/events BaseAnalyticsEventMap + open AnalyticsEventMap registry
64
+ ├─ Privacy src/privacy key classification + property sanitization pipeline
65
+
66
+
67
+ AnalyticsAdapter src/providers the only contract the core depends on
68
+
69
+
70
+ PlausibleAdapter src/providers/plausible window.plausible, script injection, revenue mapping
71
+
72
+
73
+ Plausible
74
+ ```
75
+
76
+ Rules the codebase enforces (an ESLint rule fails the build otherwise):
77
+
78
+ - the core never imports React
79
+ - only `src/providers/plausible/**` may reference `window.plausible`
80
+ - provider internals are not part of the default public API
81
+
82
+ ### Build tooling
83
+
84
+ | Concern | Choice |
85
+ | --------------- | --------------------------------------------------------------------------------- |
86
+ | Library bundle | **tsup** (esbuild) → ESM + per-entry `.d.ts`, tree-shakeable, `sideEffects:false` |
87
+ | Tests | **Vitest** + **React Testing Library** (jsdom) |
88
+ | Lint / format | **ESLint 9** (flat config, type-checked) + **Prettier** |
89
+ | Example app | **Vite 6** |
90
+ | Package manager | **pnpm** |
91
+
92
+ The package ships **ESM only** — the target consumers are React 19 + Vite apps
93
+ and other modern ESM projects.
94
+
95
+ ## Installation
96
+
97
+ ```bash
98
+ pnpm add @pl4yzonellc/empire-analytics
99
+ # react + react-dom 19 are peer dependencies
100
+
101
+ # only if you set plausible.mode: 'package' (optional peer dependency):
102
+ pnpm add @plausible-analytics/tracker
103
+ ```
104
+
105
+ Published to the public npm registry (`--access public`).
106
+ To consume it from a private/company registry instead, point your `.npmrc` at one:
107
+
108
+ ```ini
109
+ @pl4yzonellc:registry=https://npm.your-company.example/
110
+ //npm.your-company.example/:_authToken=${NPM_TOKEN}
111
+ ```
112
+
113
+ ## Basic React setup
114
+
115
+ ```tsx
116
+ // main.tsx
117
+ import { StrictMode } from 'react';
118
+ import { createRoot } from 'react-dom/client';
119
+ import { AnalyticsProvider } from '@pl4yzonellc/empire-analytics';
120
+ import { App } from './App';
121
+
122
+ createRoot(document.getElementById('root')!).render(
123
+ <StrictMode>
124
+ <AnalyticsProvider
125
+ config={{
126
+ provider: 'plausible',
127
+ environment: import.meta.env.MODE as 'development' | 'test' | 'staging' | 'production',
128
+ plausible: { domain: 'example.com' },
129
+ }}
130
+ >
131
+ <App />
132
+ </AnalyticsProvider>
133
+ </StrictMode>,
134
+ );
135
+ ```
136
+
137
+ Inside any component:
138
+
139
+ ```tsx
140
+ import { useAnalytics } from '@pl4yzonellc/empire-analytics';
141
+
142
+ function ContactForm() {
143
+ const analytics = useAnalytics();
144
+
145
+ return (
146
+ <form
147
+ onSubmit={() => {
148
+ analytics.track('contact_form_submitted', { form: 'main-contact' });
149
+ }}
150
+ >
151
+ {/* … */}
152
+ </form>
153
+ );
154
+ }
155
+ ```
156
+
157
+ `useTrackEvent()` returns a stable, typed `track` function that is convenient in
158
+ dependency arrays:
159
+
160
+ ```tsx
161
+ import { useEffect } from 'react';
162
+ import { useTrackEvent } from '@pl4yzonellc/empire-analytics';
163
+
164
+ function PricingPage() {
165
+ const track = useTrackEvent();
166
+ useEffect(() => track('product_viewed', { productId: 'pro-plan' }), [track]);
167
+ return null;
168
+ }
169
+ ```
170
+
171
+ The `<AnalyticsProvider>`:
172
+
173
+ - initializes analytics on mount and destroys it on unmount
174
+ - re-initializes only when a **meaningful** config field changes (changing an
175
+ `onError`/`onEvent` callback identity does **not** re-initialize)
176
+ - is **StrictMode-safe** — initialization is idempotent and a stale double-invoke
177
+ cannot resurrect a destroyed client
178
+ - contains zero Plausible-specific behavior
179
+
180
+ Place it once, near the root.
181
+
182
+ ## Non-React / global usage
183
+
184
+ The same client is available without React. It is safe to import anywhere — no
185
+ script is injected at import time.
186
+
187
+ ```ts
188
+ import { analytics } from '@pl4yzonellc/empire-analytics';
189
+
190
+ // during app startup, before any global usage:
191
+ await analytics.initialize({
192
+ provider: 'plausible',
193
+ environment: 'production',
194
+ plausible: { domain: 'example.com' },
195
+ });
196
+
197
+ // anywhere else, e.g. a checkout service module:
198
+ analytics.track('checkout_started', { cartValue: 125, itemCount: 3 });
199
+ ```
200
+
201
+ By default `<AnalyticsProvider>` initializes **this same shared `analytics`
202
+ instance**, so a React app that renders the provider also configures the global
203
+ client. Pass `client={createAnalyticsClient()}` to the provider if you need an
204
+ isolated instance instead.
205
+
206
+ For fully independent clients:
207
+
208
+ ```ts
209
+ import { createAnalyticsClient } from '@pl4yzonellc/empire-analytics';
210
+
211
+ const analytics = createAnalyticsClient();
212
+ ```
213
+
214
+ ## Configuration
215
+
216
+ ```ts
217
+ interface AnalyticsConfig {
218
+ provider: 'plausible' | 'console' | 'custom';
219
+ environment: 'development' | 'test' | 'staging' | 'production';
220
+
221
+ enabled?: boolean; // explicit master switch — always wins over the env default
222
+ debug?: boolean; // verbose logging + stricter privacy; default: true in development
223
+
224
+ tenantKey?: string; // multi-tenant metadata; NOT sent unless includeTenantKey
225
+ includeTenantKey?: boolean; // default false
226
+ tenantPropertyName?: string; // default 'tenant'
227
+
228
+ defaultProperties?: Record<string, unknown>; // merged into every event (lowest precedence)
229
+
230
+ privacy?: {
231
+ onSensitive?: 'strip' | 'warn' | 'throw'; // default: throw in dev, warn on staging/test, strip in prod
232
+ onPii?: 'allow' | 'strip' | 'warn' | 'throw'; // default: warn
233
+ blockKeys?: string[]; // extra always-blocked keys (substring match)
234
+ allowKeys?: string[]; // force-allow keys a built-in rule would block
235
+ maxDepth?: number; // default 4
236
+ maxProperties?: number; // default 64
237
+ maxStringLength?: number; // default 1024
238
+ maxArrayLength?: number; // default 64
239
+ };
240
+
241
+ onError?: (error: AnalyticsError) => void; // observability; never receives provider internals
242
+ onEvent?: (info: AnalyticsEventInfo) => void; // observability; fires for every track/pageView attempt
243
+
244
+ // provider block — the discriminated union requires the one matching `provider`
245
+ plausible?: {
246
+ domain: string; // required for provider: 'plausible'
247
+ mode?: 'cdn' | 'package'; // default 'cdn' (hosted script); 'package' = @plausible-analytics/tracker
248
+ scriptUrl?: string; // 'cdn' mode: self-hosted / first-party proxy
249
+ endpoint?: string; // custom events API
250
+ injectScript?: boolean; // 'cdn' mode: default true; false if the host page already includes the snippet
251
+ autoPageViews?: boolean; // default true; false = manual page views only
252
+ hashRouting?: boolean;
253
+ outboundLinks?: boolean;
254
+ fileDownloads?: boolean;
255
+ taggedEvents?: boolean;
256
+ revenue?: boolean; // default true
257
+ trackLocalhost?: boolean;
258
+ };
259
+ console?: { prefix?: string; sink?: Pick<Console, 'log' | 'info' | 'group' | 'groupEnd'> };
260
+ adapter?: (context: { logger: Logger }) => AnalyticsAdapter; // for provider: 'custom'
261
+ }
262
+ ```
263
+
264
+ Configuration can come from **anywhere** — a runtime config service, a JSON file
265
+ served per tenant, or Vite env vars. Nothing in this package assumes
266
+ `import.meta.env`.
267
+
268
+ ```jsonc
269
+ // e.g. GET /config → consumed by your app and passed to <AnalyticsProvider>
270
+ {
271
+ "analytics": {
272
+ "enabled": true,
273
+ "provider": "plausible",
274
+ "domain": "client-a.example.com",
275
+ },
276
+ }
277
+ ```
278
+
279
+ Invalid configuration (unknown environment/provider, missing `plausible.domain`,
280
+ missing custom `adapter`) throws `AnalyticsConfigError` from `initialize()`.
281
+
282
+ ## Environment behavior
283
+
284
+ | `environment` | default when `enabled` is omitted |
285
+ | ------------- | ------------------------------------------ |
286
+ | `development` | **disabled** |
287
+ | `test` | **disabled** |
288
+ | `staging` | **disabled** (opt in with `enabled: true`) |
289
+ | `production` | **enabled** |
290
+
291
+ An explicit `enabled` **always** overrides the default — `enabled: true` in
292
+ `development`, `enabled: false` in `production`, both honored.
293
+
294
+ `debug` defaults to `true` in `development` only, and can be set explicitly in any
295
+ environment.
296
+
297
+ While disabled the client is inert: no provider is created, **no script is
298
+ injected**, `track()` / `pageView()` are no-ops (reported through `onEvent` with
299
+ `delivered: false`), and `isEnabled()` returns `false`.
300
+
301
+ Consent flows can toggle at runtime:
302
+
303
+ ```ts
304
+ analytics.setEnabled(true); // lazily starts the provider if it was disabled at init
305
+ ```
306
+
307
+ ## Built-in event catalog
308
+
309
+ Reuse these standardized names instead of inventing near-duplicates. Each has a
310
+ typed property model (`?` = optional).
311
+
312
+ <details>
313
+ <summary><strong>Common</strong></summary>
314
+
315
+ | Event | Properties |
316
+ | ----------------------- | ---------------------------- |
317
+ | `cta_clicked` | `id?`, `label?`, `location?` |
318
+ | `outbound_link_clicked` | `url`, `location?` |
319
+ | `download_clicked` | `file`, `location?` |
320
+ | `search_performed` | `query?`, `resultsCount?` |
321
+
322
+ </details>
323
+
324
+ <details>
325
+ <summary><strong>Lead generation</strong></summary>
326
+
327
+ | Event | Properties |
328
+ | ------------------------ | ------------------------------ |
329
+ | `contact_form_started` | `form` |
330
+ | `contact_form_submitted` | `form` |
331
+ | `phone_clicked` | `location?` |
332
+ | `email_clicked` | `location?` |
333
+ | `appointment_started` | `service?` |
334
+ | `appointment_completed` | `service?`, `durationMinutes?` |
335
+
336
+ </details>
337
+
338
+ <details>
339
+ <summary><strong>Authentication</strong></summary>
340
+
341
+ | Event | Properties |
342
+ | ------------------ | -------------------- |
343
+ | `login_started` | `method?` |
344
+ | `login_completed` | `method?` |
345
+ | `login_failed` | `method?`, `reason?` |
346
+ | `signup_started` | `method?` |
347
+ | `signup_completed` | `method?`, `plan?` |
348
+
349
+ </details>
350
+
351
+ <details>
352
+ <summary><strong>Ecommerce</strong></summary>
353
+
354
+ | Event | Properties |
355
+ | -------------------- | -------------------------------------------------------- |
356
+ | `product_viewed` | `productId`, `name?`, `price?`, `currency?`, `category?` |
357
+ | `add_to_cart` | `productId`, `quantity?`, `price?`, `currency?` |
358
+ | `remove_from_cart` | `productId`, `quantity?` |
359
+ | `checkout_started` | `cartValue?`, `itemCount?`, `currency?` |
360
+ | `checkout_completed` | `cartValue?`, `itemCount?`, `currency?` |
361
+ | `purchase_completed` | `revenue`, `currency`, `orderId?`, `itemCount?` |
362
+
363
+ </details>
364
+
365
+ <details>
366
+ <summary><strong>Engagement</strong></summary>
367
+
368
+ | Event | Properties |
369
+ | --------------------- | ----------------------------------- |
370
+ | `video_started` | `id?`, `title?` |
371
+ | `video_completed` | `id?`, `title?`, `durationSeconds?` |
372
+ | `document_downloaded` | `file`, `category?` |
373
+ | `social_link_clicked` | `network`, `location?` |
374
+
375
+ </details>
376
+
377
+ Runtime helpers: `ANALYTICS_EVENT_CATALOG` (grouped), `ALL_ANALYTICS_EVENTS`
378
+ (flat), `isKnownAnalyticsEvent(name)`.
379
+
380
+ ## Application-specific events
381
+
382
+ The catalog is not a ceiling. Extend the open `AnalyticsEventMap` registry with
383
+ **TypeScript module augmentation** — one file, no generics:
384
+
385
+ ```ts
386
+ // src/analytics-events.ts (import it once, e.g. from main.tsx)
387
+ import '@pl4yzonellc/empire-analytics';
388
+
389
+ declare module '@pl4yzonellc/empire-analytics' {
390
+ interface AnalyticsEventMap {
391
+ quote_requested: { service: string; budgetRange?: string };
392
+ newsletter_subscribed: { source: string };
393
+ }
394
+ }
395
+ ```
396
+
397
+ From then on, everywhere (`analytics.track`, `useTrackEvent`, `<Track>`):
398
+
399
+ ```ts
400
+ analytics.track('quote_requested', { service: 'roofing' }); // ✅ typed
401
+ analytics.track('quote_requestd', { service: 'roofing' }); // ❌ compile error (typo)
402
+ analytics.track('quote_requested', {}); // ❌ compile error (missing `service`)
403
+ ```
404
+
405
+ - standard events stay standardized
406
+ - app events are strongly typed
407
+ - arbitrary typo-prone strings are rejected by the compiler
408
+
409
+ `AnalyticsEventMap` already `extends BaseAnalyticsEventMap`, so the whole catalog
410
+ is always available.
411
+
412
+ ## Event properties
413
+
414
+ - events with a required property force the `properties` argument; events with
415
+ only optional properties make it optional
416
+ - `defaultProperties` from config are merged in with the **lowest** precedence
417
+ - values are sanitized before they reach the adapter (see
418
+ [Privacy protection](#privacy-protection))
419
+
420
+ ## Revenue events
421
+
422
+ Revenue is a first-class SDK concept. Applications never learn Plausible's payload
423
+ shape.
424
+
425
+ ```ts
426
+ analytics.track('purchase_completed', {
427
+ revenue: 49.99,
428
+ currency: 'USD',
429
+ orderId: 'abc123',
430
+ });
431
+ ```
432
+
433
+ The Plausible adapter translates `{ revenue, currency }` into
434
+ `{ revenue: { amount, currency } }` and forwards the remaining fields as props.
435
+ `{ revenue: { amount, currency } }` is also accepted. Configure the matching goal
436
+ as a revenue goal in the Plausible dashboard; keep `plausible.revenue` enabled
437
+ (the default).
438
+
439
+ Raw financial / payment-card fields (`cardNumber`, `cvv`, …) are stripped by the
440
+ privacy layer and never forwarded.
441
+
442
+ ## Privacy protection
443
+
444
+ A two-tier key classifier runs on every property bag (including nested objects and
445
+ arrays). Matching is on a normalized key — `credit_card`, `credit-card`,
446
+ `creditCard` all match.
447
+
448
+ **Always blocked (`sensitive`)** — credentials, tokens, card data, government ids:
449
+ `password`, `passcode`, `token`, `accessToken`, `refreshToken`, `authorization`,
450
+ `jwt`, `apiKey`, `clientSecret`, `creditCard`, `cardNumber`, `cvv`, `ssn`,
451
+ `socialSecurityNumber`, `iban`, `routingNumber`, `sessionId`, … (see
452
+ `DEFAULT_SENSITIVE_KEYS`).
453
+
454
+ **Configurable (`pii`)** — direct identifiers: `email`, `phone`, `phoneNumber`,
455
+ `firstName`, `lastName`, `fullName`, `address`, `streetAddress`, `dateOfBirth`,
456
+ `ipAddress`, … (see `DEFAULT_PII_KEYS`).
457
+
458
+ | Policy | `development` / `debug` | `test` / `staging` | `production` |
459
+ | ------------- | ----------------------- | ------------------ | ------------------------------------------------------------- |
460
+ | `onSensitive` | `throw` (default) | `warn` (default) | `strip` (default; `throw`/`warn` are **downgraded to strip**) |
461
+ | `onPii` | `warn` (default) | `warn` (default) | `warn` (default; `throw` downgraded) |
462
+
463
+ **Production never crashes** because analytics saw a bad property — `throw` is
464
+ clamped. In development a blocked `sensitive` key raises `AnalyticsPrivacyError`
465
+ (carrying the **key path only**, never the value) so mistakes surface early.
466
+
467
+ The sanitization pipeline also:
468
+
469
+ - removes functions, symbols, `undefined`, `null`, `NaN`/`Infinity`
470
+ - converts `Date` → ISO string, `bigint` → string
471
+ - breaks circular references instead of throwing
472
+ - caps depth, property count, string length and array length
473
+ - truncates over-long strings
474
+
475
+ Tune it per app:
476
+
477
+ ```ts
478
+ privacy: {
479
+ onPii: 'strip', // silently drop identifiers in every environment
480
+ allowKeys: ['country'], // ...but keep a coarse `country`
481
+ blockKeys: ['internalMemberId'] // project-specific always-blocked key
482
+ }
483
+ ```
484
+
485
+ Blocked **values** are never logged, stored, or attached to any error.
486
+
487
+ ## Debugging
488
+
489
+ ```ts
490
+ {
491
+ debug: true;
492
+ }
493
+ ```
494
+
495
+ Turns on a small internal logger (the library has no scattered `console.*`
496
+ calls). Typical output:
497
+
498
+ ```text
499
+ [Analytics] Event {
500
+ type: 'event',
501
+ name: 'checkout_started',
502
+ provider: 'plausible',
503
+ enabled: true,
504
+ environment: 'development',
505
+ properties: { cartValue: 125, itemCount: 3 }, // sanitized
506
+ blockedKeys: [] // key paths removed by privacy (names only)
507
+ }
508
+ ```
509
+
510
+ When disabled you also get a reason:
511
+
512
+ ```text
513
+ [Analytics] Event skipped {
514
+ name: 'checkout_started',
515
+ reason: 'analytics disabled by default for the "development" environment'
516
+ }
517
+ ```
518
+
519
+ With `debug` off the logger is completely silent; errors still reach `onError`.
520
+
521
+ The `console` provider (`provider: 'console'`) prints every event to the console
522
+ instead of sending it — handy for local development without a Plausible account.
523
+
524
+ ## Explicit page views
525
+
526
+ Plausible tracks SPA navigation itself, so **you do not need `useEffect` route
527
+ tracking** and this package is **not coupled to any router**.
528
+
529
+ Use `pageView()` only for special cases (virtual pages, multi-step wizards,
530
+ modals treated as pages):
531
+
532
+ ```ts
533
+ analytics.pageView('/checkout/step-2');
534
+ ```
535
+
536
+ Sensitive query parameters (`?token=…`, `?password=…`) are stripped from the path
537
+ before it is sent.
538
+
539
+ If your app uses hash routing, set `plausible.hashRouting: true`. To take full
540
+ manual control, set `plausible.autoPageViews: false` (loads the `manual` script
541
+ variant) and call `pageView()` yourself.
542
+
543
+ ## Multi-tenant configuration
544
+
545
+ `tenantKey` is treated as **application metadata**, not analytics data. It is not
546
+ sent unless you opt in:
547
+
548
+ ```ts
549
+ {
550
+ tenantKey: 'client-a',
551
+ includeTenantKey: true, // now every event carries…
552
+ tenantPropertyName: 'tenant', // …{ tenant: 'client-a' }
553
+ }
554
+ ```
555
+
556
+ Because configuration is just an object, a host app can build it from its own
557
+ per-tenant runtime config service (domain, enabled flag, provider) and hand it to
558
+ `<AnalyticsProvider>` or `analytics.initialize()`. Vite env vars are one possible
559
+ source, never the only one.
560
+
561
+ ## Testing consuming applications
562
+
563
+ Import from the dedicated `/testing` entry point (kept out of the production
564
+ bundle). Tests never load Plausible and never send a real event.
565
+
566
+ ```ts
567
+ import {
568
+ createMockAnalyticsClient,
569
+ createTestAnalyticsClient,
570
+ MemoryAdapter,
571
+ } from '@pl4yzonellc/empire-analytics/testing';
572
+ ```
573
+
574
+ ### `createMockAnalyticsClient()` — a zero-dependency fake
575
+
576
+ ```ts
577
+ import { AnalyticsProvider } from '@pl4yzonellc/empire-analytics';
578
+ import { createMockAnalyticsClient } from '@pl4yzonellc/empire-analytics/testing';
579
+
580
+ const analytics = createMockAnalyticsClient({ spy: (fn) => vi.fn(fn) });
581
+
582
+ render(
583
+ <AnalyticsProvider config={config} client={analytics}>
584
+ <App />
585
+ </AnalyticsProvider>,
586
+ );
587
+
588
+ await userEvent.click(screen.getByRole('button', { name: 'Buy' }));
589
+
590
+ expect(analytics.track).toHaveBeenCalledWith('checkout_started', expect.anything());
591
+ // or, without a spy library:
592
+ expect(analytics.events).toContainEqual({
593
+ name: 'checkout_started',
594
+ properties: { cartValue: 125 },
595
+ });
596
+ ```
597
+
598
+ Pass `spy: (fn) => vi.fn(fn)` (or `jest.fn`) to make the methods assertable with
599
+ `toHaveBeenCalledWith`. Without it, inspect `analytics.events` / `analytics.pageViews`.
600
+ `analytics.reset()` clears recorded calls.
601
+
602
+ ### `createTestAnalyticsClient()` — the real pipeline, in memory
603
+
604
+ Runs the actual core (privacy sanitization, tenant handling, enabled logic) into a
605
+ `MemoryAdapter`:
606
+
607
+ ```ts
608
+ const { client, adapter } = createTestAnalyticsClient({ defaultProperties: { app: 'web' } });
609
+
610
+ client.track('checkout_started', { cartValue: 125, password: 'nope' });
611
+
612
+ expect(adapter.lastEvent).toEqual({
613
+ name: 'checkout_started',
614
+ properties: { app: 'web', cartValue: 125 }, // password stripped by the real privacy layer
615
+ });
616
+ ```
617
+
618
+ ### `MemoryAdapter`
619
+
620
+ Use directly with `provider: 'custom'` to assert exactly what the SDK forwards to
621
+ a provider.
622
+
623
+ ## How the Plausible adapter works
624
+
625
+ `PlausibleAdapter` (in `src/providers/plausible/`) is the only code that knows
626
+ about Plausible. It has two runtimes, chosen by `plausible.mode`.
627
+
628
+ ### `mode: 'cdn'` (default)
629
+
630
+ - **Script loading** — composes the CDN URL from the enabled script variants
631
+ (`script.revenue.manual.js`, …), injects a single deferred `<script data-domain>`
632
+ tag, and installs the `window.plausible` queue stub so early calls are buffered.
633
+ Injection is **idempotent**: a second adapter, a repeat `initialize()`, or a
634
+ hand-added snippet in `index.html` never produces a duplicate tag. Set
635
+ `injectScript: false` if your host page already includes the script;
636
+ `scriptUrl` points at a self-hosted instance or first-party proxy.
637
+ - **Page views** — `pageView(path)` sends `plausible('pageview', { u })`.
638
+ - **Cleanup** — `destroy()` removes the injected script and the queue stub it created.
639
+
640
+ ### `mode: 'package'` — [`@plausible-analytics/tracker`](https://www.npmjs.com/package/@plausible-analytics/tracker)
641
+
642
+ - An **optional peer dependency**. The consuming app runs `pnpm add @plausible-analytics/tracker`;
643
+ the adapter reaches it through a lazy `import()`, so apps that don't opt in never
644
+ download it and it is not in the SDK's bundle.
645
+ - **No remote `<script>`** — avoids the heavily blocklisted `plausible.io/js/script.js`
646
+ filename, pins the tracker version, and needs no `script-src` CSP entry. `initialize()`
647
+ calls the package's `init()` with `bindToWindow: false` (so `window.plausible` is
648
+ never created), mapping the SDK options onto `autoCapturePageviews`,
649
+ `hashBasedRouting`, `outboundLinks`, `fileDownloads`, `captureOnLocalhost`.
650
+ - **Page views** — `pageView(path)` calls `track('pageview', { url })`.
651
+ - **Cleanup** — the package exposes no teardown; `destroy()` drops the adapter's
652
+ reference so no further events are sent.
653
+ - A missing package produces a generic `AnalyticsError` (`provider_unavailable`)
654
+ routed to `onError` — never an unhandled module-resolution crash.
655
+ - `scriptUrl`, `injectScript` and `taggedEvents` do not apply in this mode.
656
+
657
+ ### Both modes
658
+
659
+ - **Events** — `track(name, props)` maps the SDK property bag to Plausible's
660
+ `{ props, revenue }` shape (see [Revenue events](#revenue-events)).
661
+ - **Resilience** — a missing `window`, a missing/throwing tracker (ad blocker,
662
+ privacy extension), or a failed load never throws. Failures are logged in `debug`
663
+ mode and routed to `onError`; generic `AnalyticsError` codes
664
+ (`script_load_failed`, `provider_unavailable`, `initialization_failed`, …) never
665
+ leak provider internals.
666
+ - **SSR** — every browser-global access is guarded, so importing and constructing
667
+ the adapter is safe where `window`/`document` are absent.
668
+
669
+ ## Adding a future provider
670
+
671
+ The core depends only on `AnalyticsAdapter`:
672
+
673
+ ```ts
674
+ interface AnalyticsAdapter {
675
+ readonly name: string;
676
+ initialize(): Promise<void> | void;
677
+ track(eventName: string, properties?: Record<string, unknown>): void;
678
+ pageView(path?: string): void;
679
+ destroy(): void;
680
+ }
681
+ ```
682
+
683
+ ### Option A — a custom adapter today (no library change)
684
+
685
+ ```ts
686
+ import type { AnalyticsAdapter } from '@pl4yzonellc/empire-analytics';
687
+
688
+ class PostHogAdapter implements AnalyticsAdapter {
689
+ readonly name = 'posthog';
690
+ initialize() {
691
+ /* load posthog-js */
692
+ }
693
+ track(name: string, props?: Record<string, unknown>) {
694
+ /* posthog.capture(name, props) */
695
+ }
696
+ pageView(path?: string) {
697
+ /* posthog.capture('$pageview', { $current_url: path }) */
698
+ }
699
+ destroy() {
700
+ /* posthog.reset() */
701
+ }
702
+ }
703
+
704
+ <AnalyticsProvider
705
+ config={{
706
+ provider: 'custom',
707
+ environment: 'production',
708
+ adapter: (ctx) => new PostHogAdapter(/* ctx.logger */),
709
+ }}
710
+ />;
711
+ ```
712
+
713
+ ### Option B — a first-class provider in the library
714
+
715
+ 1. add `src/providers/<name>/<Name>Adapter.ts` implementing `AnalyticsAdapter`
716
+ 2. add a `<Name>ProviderConfig` type and a `<name>` block to the config union in
717
+ `src/core/types.ts`
718
+ 3. add a `case '<name>'` to `createAdapter()` in `src/providers/factory.ts`
719
+ 4. resolve its defaults in `src/core/config.ts`
720
+
721
+ Application-facing APIs (`track`, `pageView`, the event catalog, hooks) do not
722
+ change.
723
+
724
+ ## Package scripts
725
+
726
+ | Script | Purpose |
727
+ | -------------------- | --------------------------------- |
728
+ | `pnpm build` | Bundle to `dist/` (ESM + `.d.ts`) |
729
+ | `pnpm test` | Run the test suite once |
730
+ | `pnpm test:watch` | Vitest watch mode |
731
+ | `pnpm test:coverage` | Coverage report |
732
+ | `pnpm typecheck` | `tsc --noEmit` |
733
+ | `pnpm lint` | ESLint |
734
+ | `pnpm format` | Prettier write |
735
+ | `pnpm format:check` | Prettier check (CI) |
736
+
737
+ `pnpm install && pnpm build && pnpm test && pnpm typecheck && pnpm lint` all pass.
738
+
739
+ A runnable demo lives in [`example/`](./example) — it uses the `console` provider
740
+ so no Plausible credentials are needed.
741
+
742
+ ## License
743
+
744
+ MIT