@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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +744 -0
- package/dist/chunk-J5GXHJNU.js +1105 -0
- package/dist/chunk-J5GXHJNU.js.map +1 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.js +117 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/index.d.ts +91 -0
- package/dist/testing/index.js +108 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-Bzaq1v4t.d.ts +507 -0
- package/package.json +97 -0
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
|