@stacksee/analytics 0.4.6 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # @stacksee/analytics
2
2
 
3
- A highly typed, provider-agnostic analytics library for TypeScript applications. Works seamlessly on both client and server sides with full type safety for your custom events.
3
+ A highly typed, zero-dependency, provider-agnostic analytics library for TypeScript applications. Works seamlessly on both client and server sides with full type safety for your custom events.
4
4
 
5
5
  ## Table of Contents
6
6
 
7
7
  - [Features](#features)
8
+ - [Providers](#providers)
8
9
  - [Installation](#installation)
9
10
  - [Quick Start](#quick-start)
10
11
  - [1. Define Your Events](#1-define-your-events)
@@ -39,11 +40,40 @@ A highly typed, provider-agnostic analytics library for TypeScript applications.
39
40
  - 🎯 **Type-safe events**: Define your own strongly typed events with full IntelliSense support
40
41
  - 🔌 **Plugin architecture**: Easily add analytics providers by passing them as plugins
41
42
  - 🌐 **Universal**: Same API works on both client (browser) and server (Node.js)
42
- - 📦 **Lightweight**: Zero dependencies on the core library
43
- - 🏗️ **Framework agnostic**: Use with any JavaScript framework
44
- - 🌎 **Edge ready**: The server client is compatible with edge runtime (e.g. Cloudflare Workers)
43
+ - 👤 **User context**: Automatically attach user data (email, traits) to all events
44
+ - 🏗️ **Framework agnostic**: Use with any JavaScript framework. Can also be used only on the client.
45
+ - 🌎 **Edge ready**: The server client is compatible with edge runtime (e.g. Cloudflare Workers, Vercel Edge functions)
45
46
  - 🔧 **Extensible**: Simple interface to add new providers
46
47
 
48
+ ## Providers
49
+
50
+ The library includes built-in support for popular analytics services, with more coming soon:
51
+
52
+ ### Official Providers
53
+
54
+ | Provider | Type | Documentation |
55
+ |----------|------|---------------|
56
+ | **PostHog** | Product Analytics | [docs/providers/posthog.md](./docs/providers/posthog.md) |
57
+ | **Bento** | Email Marketing & Events | [docs/providers/bento.md](./docs/providers/bento.md) |
58
+ | **Pirsch** | Privacy-Focused Web Analytics | [docs/providers/pirsch.md](./docs/providers/pirsch.md) |
59
+
60
+ ### Community & Custom Providers
61
+
62
+ Want to use a different analytics service? Check out our guide:
63
+
64
+ **[Creating Custom Providers →](./docs/providers/custom-providers.md)**
65
+
66
+ You can easily create providers for:
67
+ - Google Analytics
68
+ - Mixpanel
69
+ - Amplitude
70
+ - Segment
71
+ - Customer.io
72
+ - Loops
73
+ - Any analytics service with a JavaScript SDK
74
+
75
+ **[View all provider documentation →](./docs/providers/)**
76
+
47
77
  ## Installation
48
78
 
49
79
  ```bash
@@ -51,8 +81,16 @@ pnpm install @stacksee/analytics
51
81
 
52
82
  # For PostHog support
53
83
  pnpm install posthog-js posthog-node
84
+
85
+ # For Bento support (server-side only)
86
+ pnpm install @bentonow/bento-node-sdk
87
+
88
+ # For Pirsch support
89
+ pnpm install pirsch-sdk
54
90
  ```
55
91
 
92
+ > **See also:** [Provider Documentation](./docs/providers/) for detailed setup guides for each provider.
93
+
56
94
  ## Quick Start
57
95
 
58
96
  ### 1. Define Your Events
@@ -126,12 +164,19 @@ analytics.track('user_signed_up', {
126
164
  // analytics.track('wrong_event', {}); // ❌ Error: Argument of type '"wrong_event"' is not assignable
127
165
  // analytics.track('user_signed_up', { wrongProp: 'value' }); // ❌ Error: Object literal may only specify known properties
128
166
 
129
- // Identify users
167
+ // Identify users - user context is automatically included in all subsequent events
130
168
  analytics.identify('user-123', {
131
169
  email: 'user@example.com',
132
170
  name: 'John Doe',
133
171
  plan: 'pro'
134
172
  });
173
+
174
+ // Now all tracked events automatically include user context
175
+ analytics.track('feature_used', {
176
+ featureName: 'export-data',
177
+ userId: 'user-123'
178
+ });
179
+ // Providers receive: context.user = { userId: 'user-123', email: 'user@example.com', traits: {...} }
135
180
  ```
136
181
 
137
182
  ### 3. Server-Side Usage
@@ -155,24 +200,201 @@ const analytics = createServerAnalytics<AppEvents>({
155
200
  enabled: true
156
201
  });
157
202
 
158
- // Track events - now returns a Promise with full type safety
203
+ // Track events with user context - now returns a Promise with full type safety
159
204
  await analytics.track('feature_used', {
160
205
  featureName: 'export-data',
161
206
  userId: 'user-123',
162
207
  duration: 1500
163
208
  }, {
164
209
  userId: 'user-123',
210
+ user: {
211
+ email: 'user@example.com',
212
+ traits: {
213
+ plan: 'pro',
214
+ company: 'Acme Corp'
215
+ }
216
+ },
165
217
  context: {
166
218
  page: {
167
219
  path: '/api/export',
168
220
  }
169
221
  }
170
222
  });
223
+ // Providers receive: context.user = { userId: 'user-123', email: 'user@example.com', traits: {...} }
171
224
 
172
225
  // Important: Always call shutdown when done, some providers such as Posthog require flushing events.
173
226
  await analytics.shutdown();
174
227
  ```
175
228
 
229
+ ## User Context
230
+
231
+ The library automatically manages user context, making it easy to include user data (email, traits) in all your analytics events. This is especially useful for providers like Loops or Intercom that require user identifiers.
232
+
233
+ ### How It Works
234
+
235
+ **Client-Side (Stateful):**
236
+ ```typescript
237
+ // 1. Identify the user once (typically after login)
238
+ analytics.identify('user-123', {
239
+ email: 'user@example.com',
240
+ name: 'John Doe',
241
+ plan: 'pro',
242
+ company: 'Acme Corp'
243
+ });
244
+
245
+ // 2. Track events - user context is automatically included
246
+ analytics.track('button_clicked', { buttonId: 'checkout' });
247
+
248
+ // Behind the scenes, providers receive:
249
+ // {
250
+ // event: { action: 'button_clicked', ... },
251
+ // context: {
252
+ // user: {
253
+ // userId: 'user-123',
254
+ // email: 'user@example.com',
255
+ // traits: { email: '...', name: '...', plan: '...', company: '...' }
256
+ // }
257
+ // }
258
+ // }
259
+
260
+ // 3. Reset on logout to clear user context
261
+ analytics.reset();
262
+ ```
263
+
264
+ **Server-Side (Stateless):**
265
+ ```typescript
266
+ // Pass user context with each track call
267
+ await analytics.track('api_request', {
268
+ endpoint: '/users',
269
+ method: 'POST'
270
+ }, {
271
+ userId: 'user-123',
272
+ user: {
273
+ email: 'user@example.com',
274
+ traits: {
275
+ plan: 'pro',
276
+ company: 'Acme Corp'
277
+ }
278
+ }
279
+ });
280
+
281
+ // Alternatively, pass via context.user
282
+ await analytics.track('api_request', { ... }, {
283
+ userId: 'user-123',
284
+ context: {
285
+ user: {
286
+ email: 'user@example.com'
287
+ }
288
+ }
289
+ });
290
+ ```
291
+
292
+ ### Using User Context in Custom Providers
293
+
294
+ When building custom providers, you can access user context from the `EventContext`:
295
+
296
+ ```typescript
297
+ export class LoopsProvider extends BaseAnalyticsProvider {
298
+ name = 'Loops';
299
+
300
+ async track(event: BaseEvent, context?: EventContext): Promise<void> {
301
+ // Access user data from context
302
+ const email = context?.user?.email;
303
+ const userId = context?.user?.userId;
304
+ const traits = context?.user?.traits;
305
+
306
+ // Loops requires either email or userId
307
+ if (!email && !userId) {
308
+ this.log('Skipping event - Loops requires email or userId');
309
+ return;
310
+ }
311
+
312
+ await this.loops.sendEvent({
313
+ ...(email && { email }),
314
+ ...(userId && { userId }),
315
+ eventName: event.action,
316
+ eventProperties: event.properties,
317
+ // Optionally include all user traits
318
+ contactProperties: traits,
319
+ });
320
+ }
321
+ }
322
+ ```
323
+
324
+ ### Security & Privacy
325
+
326
+ User context is handled securely:
327
+
328
+ - ✅ **Memory-only storage** - No localStorage, cookies, or persistence
329
+ - ✅ **Session-scoped** - Cleared on `reset()` (logout)
330
+ - ✅ **Provider-controlled** - Only sent to providers you configure
331
+ - ✅ **No cross-session leaks** - Fresh state on each page load
332
+
333
+ ### Type-Safe User Traits
334
+
335
+ You can define a custom interface for your user traits to get full type safety:
336
+
337
+ ```typescript
338
+ // Define your user traits interface
339
+ interface UserTraits {
340
+ email: string;
341
+ name: string;
342
+ plan: 'free' | 'pro' | 'enterprise';
343
+ company?: string;
344
+ role?: 'admin' | 'user' | 'viewer';
345
+ }
346
+
347
+ // Client-side with typed traits
348
+ const analytics = createClientAnalytics<typeof AppEvents, UserTraits>({
349
+ providers: [/* ... */]
350
+ });
351
+
352
+ // Now identify() and traits are fully typed!
353
+ analytics.identify('user-123', {
354
+ email: 'user@example.com',
355
+ name: 'John Doe',
356
+ plan: 'pro', // ✅ Autocomplete works!
357
+ company: 'Acme Corp',
358
+ role: 'admin'
359
+ });
360
+
361
+ // TypeScript will error on invalid trait values
362
+ analytics.identify('user-123', {
363
+ plan: 'invalid' // ❌ Error: Type '"invalid"' is not assignable to type 'free' | 'pro' | 'enterprise'
364
+ });
365
+
366
+ // Server-side with typed traits
367
+ const serverAnalytics = createServerAnalytics<typeof AppEvents, UserTraits>({
368
+ providers: [/* ... */]
369
+ });
370
+
371
+ await serverAnalytics.track('event', {}, {
372
+ user: {
373
+ email: 'user@example.com',
374
+ plan: 'pro', // ✅ Fully typed!
375
+ traits: {
376
+ company: 'Acme Corp'
377
+ }
378
+ }
379
+ });
380
+ ```
381
+
382
+ **Benefits:**
383
+ - ✅ Full IntelliSense/autocomplete for user traits
384
+ - ✅ Compile-time type checking prevents typos
385
+ - ✅ Self-documenting code
386
+ - ✅ Refactoring safety
387
+
388
+ ### Client vs Server Differences
389
+
390
+ | Feature | Client (Browser) | Server (Node.js) |
391
+ |---------|------------------|------------------|
392
+ | **State Management** | Stateful - persists after `identify()` | Stateless - pass per request |
393
+ | **Usage Pattern** | Call `identify()` once, track many times | Pass `user` option with each `track()` |
394
+ | **Reset** | Call `reset()` on logout | No reset needed (stateless) |
395
+ | **Use Case** | Single user per session | Multiple users per instance |
396
+ | **Type Safety** | `createClientAnalytics<Events, Traits>` | `createServerAnalytics<Events, Traits>` |
397
+
176
398
  ### Async Tracking: When to await vs fire-and-forget
177
399
 
178
400
  The `track()` method now returns a `Promise<void>`, giving you control over how to handle event tracking:
@@ -521,32 +743,21 @@ export const appEvents = {
521
743
 
522
744
  ### Adding Custom Providers
523
745
 
524
- Implement the `AnalyticsProvider` interface to add support for other analytics services:
746
+ Want to integrate with a different analytics service? See our comprehensive guide:
747
+
748
+ **[Creating Custom Providers →](./docs/providers/custom-providers.md)**
749
+
750
+ Quick example:
525
751
 
526
752
  ```typescript
527
753
  import { BaseAnalyticsProvider, BaseEvent, EventContext } from '@stacksee/analytics';
528
754
 
529
755
  export class GoogleAnalyticsProvider extends BaseAnalyticsProvider {
530
756
  name = 'GoogleAnalytics';
531
- private measurementId: string;
532
-
533
- constructor(config: { measurementId: string; debug?: boolean; enabled?: boolean }) {
534
- super({ debug: config.debug, enabled: config.enabled });
535
- this.measurementId = config.measurementId;
536
- }
537
-
538
- async initialize(): Promise<void> {
539
- // Initialize GA
540
- }
541
-
542
- track(event: BaseEvent, context?: EventContext): void {
543
- // Send event to GA
544
- }
545
-
546
- identify(userId: string, traits?: Record<string, unknown>): void {
547
- // Set user properties in GA
548
- }
549
757
 
758
+ async initialize(): Promise<void> { /* Initialize GA */ }
759
+ track(event: BaseEvent, context?: EventContext): void { /* Track event */ }
760
+ identify(userId: string, traits?: Record<string, unknown>): void { /* Identify user */ }
550
761
  // ... implement other required methods
551
762
  }
552
763
  ```
@@ -554,9 +765,9 @@ export class GoogleAnalyticsProvider extends BaseAnalyticsProvider {
554
765
  Then use it as a plugin in your configuration:
555
766
 
556
767
  ```typescript
557
- const analytics = await createClientAnalytics<typeof AppEvents>({
768
+ const analytics = createClientAnalytics<typeof AppEvents>({
558
769
  providers: [
559
- new PostHogClientProvider({ apiKey: 'xxx' }),
770
+ new PostHogClientProvider({ token: 'xxx' }),
560
771
  new GoogleAnalyticsProvider({ measurementId: 'xxx' })
561
772
  ]
562
773
  });
@@ -567,7 +778,7 @@ const analytics = await createClientAnalytics<typeof AppEvents>({
567
778
  **Important**: To avoid bundling Node.js dependencies in your client code, always use the environment-specific provider imports:
568
779
 
569
780
  - **Client-side**: `@stacksee/analytics/providers/client` - Only includes browser-compatible providers
570
- - **Server-side**: `@stacksee/analytics/providers/server` - Only includes Node.js providers
781
+ - **Server-side**: `@stacksee/analytics/providers/server` - Only includes Node.js providers
571
782
  - **Both**: `@stacksee/analytics/providers` - Includes all providers (may cause bundling issues in browsers)
572
783
 
573
784
  Some analytics libraries are designed to work only in specific environments. For example:
@@ -799,11 +1010,11 @@ const analytics = createClientAnalytics<typeof AppEvents>({
799
1010
  ```
800
1011
 
801
1012
  #### `BrowserAnalytics<TEventMap>`
802
- - `track(eventName, properties): Promise<void>` - Track an event with type-safe event names and properties
803
- - `identify(userId, traits)` - Identify a user
1013
+ - `track(eventName, properties): Promise<void>` - Track an event with type-safe event names and properties. User context from `identify()` is automatically included.
1014
+ - `identify(userId, traits)` - Identify a user and store their traits. All subsequent `track()` calls will include this user context.
804
1015
  - `pageView(properties)` - Track a page view
805
1016
  - `pageLeave(properties)` - Track a page leave event
806
- - `reset()` - Reset user session
1017
+ - `reset()` - Reset user session, clearing userId and user traits
807
1018
  - `updateContext(context)` - Update event context
808
1019
 
809
1020
  ### Server API
@@ -825,8 +1036,12 @@ const analytics = createServerAnalytics<AppEvents>({
825
1036
  ```
826
1037
 
827
1038
  #### `ServerAnalytics<TEventMap>`
828
- - `track(eventName, properties, options): Promise<void>` - Track an event with type-safe event names and properties
829
- - `identify(userId, traits)` - Identify a user
1039
+ - `track(eventName, properties, options): Promise<void>` - Track an event with type-safe event names and properties. Pass user context via `options.user` or `options.context.user`.
1040
+ - `options.userId` - User ID for this event
1041
+ - `options.sessionId` - Session ID for this event
1042
+ - `options.user` - User context (email, traits) for this event
1043
+ - `options.context` - Additional event context (page, device, etc.)
1044
+ - `identify(userId, traits)` - Identify a user (sends to providers but doesn't persist on server)
830
1045
  - `pageView(properties, options)` - Track a page view
831
1046
  - `pageLeave(properties, options)` - Track a page leave event
832
1047
  - `shutdown()` - Flush pending events and cleanup
@@ -1,86 +0,0 @@
1
- var h = Object.defineProperty;
2
- var p = (a, s, e) => s in a ? h(a, s, { enumerable: !0, configurable: !0, writable: !0, value: e }) : a[s] = e;
3
- var t = (a, s, e) => p(a, typeof s != "symbol" ? s + "" : s, e);
4
- import { B as d } from "./base.provider-AfFL5W_P.js";
5
- function o() {
6
- return typeof window < "u" && typeof window.document < "u";
7
- }
8
- class u extends d {
9
- constructor(e) {
10
- super({ debug: e.debug, enabled: e.enabled });
11
- t(this, "name", "PostHog-Client");
12
- t(this, "posthog");
13
- t(this, "initialized", !1);
14
- t(this, "config");
15
- this.config = e;
16
- }
17
- async initialize() {
18
- if (this.isEnabled() && !this.initialized) {
19
- if (!o()) {
20
- this.log("Skipping initialization - not in browser environment");
21
- return;
22
- }
23
- if (!this.config.token || typeof this.config.token != "string")
24
- throw new Error("PostHog requires a token");
25
- try {
26
- const { default: e } = await import("posthog-js"), { token: i, debug: r, ...g } = this.config;
27
- e.init(i, {
28
- ...g,
29
- debug: r ?? this.debug
30
- }), this.posthog = e, this.initialized = !0, this.log("Initialized successfully", this.config);
31
- } catch (e) {
32
- throw console.error("[PostHog-Client] Failed to initialize:", e), e;
33
- }
34
- }
35
- }
36
- identify(e, i) {
37
- !this.isEnabled() || !this.initialized || !this.posthog || (this.posthog.identify(e, i), this.log("Identified user", { userId: e, traits: i }));
38
- }
39
- track(e, i) {
40
- if (!this.isEnabled() || !this.initialized || !this.posthog) return;
41
- const r = {
42
- ...e.properties,
43
- category: e.category,
44
- timestamp: e.timestamp || Date.now(),
45
- ...e.userId && { userId: e.userId },
46
- ...e.sessionId && { sessionId: e.sessionId },
47
- ...(i == null ? void 0 : i.page) && { $current_url: i.page.path },
48
- ...(i == null ? void 0 : i.device) && { device: i.device },
49
- ...(i == null ? void 0 : i.utm) && { utm: i.utm }
50
- };
51
- this.posthog.capture(e.action, r), this.log("Tracked event", { event: e, context: i });
52
- }
53
- pageView(e, i) {
54
- if (!this.isEnabled() || !this.initialized || !this.posthog || !o())
55
- return;
56
- const r = {
57
- ...e,
58
- ...(i == null ? void 0 : i.page) && {
59
- path: i.page.path,
60
- title: i.page.title,
61
- referrer: i.page.referrer
62
- }
63
- };
64
- this.posthog.capture("$pageview", r), this.log("Tracked page view", { properties: e, context: i });
65
- }
66
- pageLeave(e, i) {
67
- if (!this.isEnabled() || !this.initialized || !this.posthog || !o())
68
- return;
69
- const r = {
70
- ...e,
71
- ...(i == null ? void 0 : i.page) && {
72
- path: i.page.path,
73
- title: i.page.title,
74
- referrer: i.page.referrer
75
- }
76
- };
77
- this.posthog.capture("$pageleave", r), this.log("Tracked page leave", { properties: e, context: i });
78
- }
79
- reset() {
80
- !this.isEnabled() || !this.initialized || !this.posthog || !o() || (this.posthog.reset(), this.log("Reset user session"));
81
- }
82
- }
83
- export {
84
- u as P,
85
- o as i
86
- };
@@ -1,84 +0,0 @@
1
- var l = Object.defineProperty;
2
- var h = (r, s, i) => s in r ? l(r, s, { enumerable: !0, configurable: !0, writable: !0, value: i }) : r[s] = i;
3
- var t = (r, s, i) => h(r, typeof s != "symbol" ? s + "" : s, i);
4
- import { B as n } from "./base.provider-AfFL5W_P.js";
5
- import { PostHog as p } from "posthog-node";
6
- class u extends n {
7
- constructor(i) {
8
- super({ debug: i.debug, enabled: i.enabled });
9
- t(this, "name", "PostHog-Server");
10
- t(this, "client");
11
- t(this, "initialized", !1);
12
- t(this, "config");
13
- this.config = i;
14
- }
15
- initialize() {
16
- if (this.isEnabled() && !this.initialized) {
17
- if (!this.config.apiKey || typeof this.config.apiKey != "string")
18
- throw new Error("PostHog requires an apiKey");
19
- try {
20
- const { apiKey: i, ...e } = this.config;
21
- this.client = new p(i, {
22
- host: "https://app.posthog.com",
23
- flushAt: 20,
24
- flushInterval: 1e4,
25
- ...e
26
- }), this.initialized = !0, this.log("Initialized successfully", this.config);
27
- } catch (i) {
28
- throw console.error("[PostHog-Server] Failed to initialize:", i), i;
29
- }
30
- }
31
- }
32
- identify(i, e) {
33
- !this.isEnabled() || !this.initialized || !this.client || (this.client.identify({
34
- distinctId: i,
35
- properties: e
36
- }), this.log("Identified user", { userId: i, traits: e }));
37
- }
38
- track(i, e) {
39
- if (!this.isEnabled() || !this.initialized || !this.client) return;
40
- const a = {
41
- ...i.properties,
42
- category: i.category,
43
- timestamp: i.timestamp ? new Date(i.timestamp) : /* @__PURE__ */ new Date(),
44
- ...i.sessionId && { sessionId: i.sessionId },
45
- ...(e == null ? void 0 : e.page) && {
46
- $current_url: e.page.path,
47
- $page_title: e.page.title,
48
- $referrer: e.page.referrer
49
- },
50
- ...(e == null ? void 0 : e.device) && { device: e.device },
51
- ...(e == null ? void 0 : e.utm) && { utm: e.utm }
52
- };
53
- this.client.capture({
54
- distinctId: i.userId || "anonymous",
55
- event: i.action,
56
- properties: a
57
- }), this.log("Tracked event", { event: i, context: e });
58
- }
59
- pageView(i, e) {
60
- if (!this.isEnabled() || !this.initialized || !this.client) return;
61
- const a = {
62
- ...i,
63
- ...(e == null ? void 0 : e.page) && {
64
- path: e.page.path,
65
- title: e.page.title,
66
- referrer: e.page.referrer
67
- }
68
- };
69
- this.client.capture({
70
- distinctId: "anonymous",
71
- event: "$pageview",
72
- properties: a
73
- }), this.log("Tracked page view", { properties: i, context: e });
74
- }
75
- async reset() {
76
- !this.isEnabled() || !this.initialized || !this.client || (await this.client.flush(), this.log("Flushed pending events"));
77
- }
78
- async shutdown() {
79
- this.client && (await this.client.shutdown(), this.log("Shutdown complete"));
80
- }
81
- }
82
- export {
83
- u as P
84
- };