@stacksee/analytics 0.4.5 → 0.5.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,6 +1,6 @@
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
 
@@ -39,9 +39,9 @@ A highly typed, provider-agnostic analytics library for TypeScript applications.
39
39
  - 🎯 **Type-safe events**: Define your own strongly typed events with full IntelliSense support
40
40
  - 🔌 **Plugin architecture**: Easily add analytics providers by passing them as plugins
41
41
  - 🌐 **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)
42
+ - 👤 **User context**: Automatically attach user data (email, traits) to all events
43
+ - 🏗️ **Framework agnostic**: Use with any JavaScript framework. Can also be used only on the client.
44
+ - 🌎 **Edge ready**: The server client is compatible with edge runtime (e.g. Cloudflare Workers, Vercel Edge functions)
45
45
  - 🔧 **Extensible**: Simple interface to add new providers
46
46
 
47
47
  ## Installation
@@ -126,12 +126,19 @@ analytics.track('user_signed_up', {
126
126
  // analytics.track('wrong_event', {}); // ❌ Error: Argument of type '"wrong_event"' is not assignable
127
127
  // analytics.track('user_signed_up', { wrongProp: 'value' }); // ❌ Error: Object literal may only specify known properties
128
128
 
129
- // Identify users
129
+ // Identify users - user context is automatically included in all subsequent events
130
130
  analytics.identify('user-123', {
131
131
  email: 'user@example.com',
132
132
  name: 'John Doe',
133
133
  plan: 'pro'
134
134
  });
135
+
136
+ // Now all tracked events automatically include user context
137
+ analytics.track('feature_used', {
138
+ featureName: 'export-data',
139
+ userId: 'user-123'
140
+ });
141
+ // Providers receive: context.user = { userId: 'user-123', email: 'user@example.com', traits: {...} }
135
142
  ```
136
143
 
137
144
  ### 3. Server-Side Usage
@@ -155,24 +162,201 @@ const analytics = createServerAnalytics<AppEvents>({
155
162
  enabled: true
156
163
  });
157
164
 
158
- // Track events - now returns a Promise with full type safety
165
+ // Track events with user context - now returns a Promise with full type safety
159
166
  await analytics.track('feature_used', {
160
167
  featureName: 'export-data',
161
168
  userId: 'user-123',
162
169
  duration: 1500
163
170
  }, {
164
171
  userId: 'user-123',
172
+ user: {
173
+ email: 'user@example.com',
174
+ traits: {
175
+ plan: 'pro',
176
+ company: 'Acme Corp'
177
+ }
178
+ },
165
179
  context: {
166
180
  page: {
167
181
  path: '/api/export',
168
182
  }
169
183
  }
170
184
  });
185
+ // Providers receive: context.user = { userId: 'user-123', email: 'user@example.com', traits: {...} }
171
186
 
172
187
  // Important: Always call shutdown when done, some providers such as Posthog require flushing events.
173
188
  await analytics.shutdown();
174
189
  ```
175
190
 
191
+ ## User Context
192
+
193
+ 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.
194
+
195
+ ### How It Works
196
+
197
+ **Client-Side (Stateful):**
198
+ ```typescript
199
+ // 1. Identify the user once (typically after login)
200
+ analytics.identify('user-123', {
201
+ email: 'user@example.com',
202
+ name: 'John Doe',
203
+ plan: 'pro',
204
+ company: 'Acme Corp'
205
+ });
206
+
207
+ // 2. Track events - user context is automatically included
208
+ analytics.track('button_clicked', { buttonId: 'checkout' });
209
+
210
+ // Behind the scenes, providers receive:
211
+ // {
212
+ // event: { action: 'button_clicked', ... },
213
+ // context: {
214
+ // user: {
215
+ // userId: 'user-123',
216
+ // email: 'user@example.com',
217
+ // traits: { email: '...', name: '...', plan: '...', company: '...' }
218
+ // }
219
+ // }
220
+ // }
221
+
222
+ // 3. Reset on logout to clear user context
223
+ analytics.reset();
224
+ ```
225
+
226
+ **Server-Side (Stateless):**
227
+ ```typescript
228
+ // Pass user context with each track call
229
+ await analytics.track('api_request', {
230
+ endpoint: '/users',
231
+ method: 'POST'
232
+ }, {
233
+ userId: 'user-123',
234
+ user: {
235
+ email: 'user@example.com',
236
+ traits: {
237
+ plan: 'pro',
238
+ company: 'Acme Corp'
239
+ }
240
+ }
241
+ });
242
+
243
+ // Alternatively, pass via context.user
244
+ await analytics.track('api_request', { ... }, {
245
+ userId: 'user-123',
246
+ context: {
247
+ user: {
248
+ email: 'user@example.com'
249
+ }
250
+ }
251
+ });
252
+ ```
253
+
254
+ ### Using User Context in Custom Providers
255
+
256
+ When building custom providers, you can access user context from the `EventContext`:
257
+
258
+ ```typescript
259
+ export class LoopsProvider extends BaseAnalyticsProvider {
260
+ name = 'Loops';
261
+
262
+ async track(event: BaseEvent, context?: EventContext): Promise<void> {
263
+ // Access user data from context
264
+ const email = context?.user?.email;
265
+ const userId = context?.user?.userId;
266
+ const traits = context?.user?.traits;
267
+
268
+ // Loops requires either email or userId
269
+ if (!email && !userId) {
270
+ this.log('Skipping event - Loops requires email or userId');
271
+ return;
272
+ }
273
+
274
+ await this.loops.sendEvent({
275
+ ...(email && { email }),
276
+ ...(userId && { userId }),
277
+ eventName: event.action,
278
+ eventProperties: event.properties,
279
+ // Optionally include all user traits
280
+ contactProperties: traits,
281
+ });
282
+ }
283
+ }
284
+ ```
285
+
286
+ ### Security & Privacy
287
+
288
+ User context is handled securely:
289
+
290
+ - ✅ **Memory-only storage** - No localStorage, cookies, or persistence
291
+ - ✅ **Session-scoped** - Cleared on `reset()` (logout)
292
+ - ✅ **Provider-controlled** - Only sent to providers you configure
293
+ - ✅ **No cross-session leaks** - Fresh state on each page load
294
+
295
+ ### Type-Safe User Traits
296
+
297
+ You can define a custom interface for your user traits to get full type safety:
298
+
299
+ ```typescript
300
+ // Define your user traits interface
301
+ interface UserTraits {
302
+ email: string;
303
+ name: string;
304
+ plan: 'free' | 'pro' | 'enterprise';
305
+ company?: string;
306
+ role?: 'admin' | 'user' | 'viewer';
307
+ }
308
+
309
+ // Client-side with typed traits
310
+ const analytics = createClientAnalytics<typeof AppEvents, UserTraits>({
311
+ providers: [/* ... */]
312
+ });
313
+
314
+ // Now identify() and traits are fully typed!
315
+ analytics.identify('user-123', {
316
+ email: 'user@example.com',
317
+ name: 'John Doe',
318
+ plan: 'pro', // ✅ Autocomplete works!
319
+ company: 'Acme Corp',
320
+ role: 'admin'
321
+ });
322
+
323
+ // TypeScript will error on invalid trait values
324
+ analytics.identify('user-123', {
325
+ plan: 'invalid' // ❌ Error: Type '"invalid"' is not assignable to type 'free' | 'pro' | 'enterprise'
326
+ });
327
+
328
+ // Server-side with typed traits
329
+ const serverAnalytics = createServerAnalytics<typeof AppEvents, UserTraits>({
330
+ providers: [/* ... */]
331
+ });
332
+
333
+ await serverAnalytics.track('event', {}, {
334
+ user: {
335
+ email: 'user@example.com',
336
+ plan: 'pro', // ✅ Fully typed!
337
+ traits: {
338
+ company: 'Acme Corp'
339
+ }
340
+ }
341
+ });
342
+ ```
343
+
344
+ **Benefits:**
345
+ - ✅ Full IntelliSense/autocomplete for user traits
346
+ - ✅ Compile-time type checking prevents typos
347
+ - ✅ Self-documenting code
348
+ - ✅ Refactoring safety
349
+
350
+ ### Client vs Server Differences
351
+
352
+ | Feature | Client (Browser) | Server (Node.js) |
353
+ |---------|------------------|------------------|
354
+ | **State Management** | Stateful - persists after `identify()` | Stateless - pass per request |
355
+ | **Usage Pattern** | Call `identify()` once, track many times | Pass `user` option with each `track()` |
356
+ | **Reset** | Call `reset()` on logout | No reset needed (stateless) |
357
+ | **Use Case** | Single user per session | Multiple users per instance |
358
+ | **Type Safety** | `createClientAnalytics<Events, Traits>` | `createServerAnalytics<Events, Traits>` |
359
+
176
360
  ### Async Tracking: When to await vs fire-and-forget
177
361
 
178
362
  The `track()` method now returns a `Promise<void>`, giving you control over how to handle event tracking:
@@ -567,7 +751,7 @@ const analytics = await createClientAnalytics<typeof AppEvents>({
567
751
  **Important**: To avoid bundling Node.js dependencies in your client code, always use the environment-specific provider imports:
568
752
 
569
753
  - **Client-side**: `@stacksee/analytics/providers/client` - Only includes browser-compatible providers
570
- - **Server-side**: `@stacksee/analytics/providers/server` - Only includes Node.js providers
754
+ - **Server-side**: `@stacksee/analytics/providers/server` - Only includes Node.js providers
571
755
  - **Both**: `@stacksee/analytics/providers` - Includes all providers (may cause bundling issues in browsers)
572
756
 
573
757
  Some analytics libraries are designed to work only in specific environments. For example:
@@ -799,11 +983,11 @@ const analytics = createClientAnalytics<typeof AppEvents>({
799
983
  ```
800
984
 
801
985
  #### `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
986
+ - `track(eventName, properties): Promise<void>` - Track an event with type-safe event names and properties. User context from `identify()` is automatically included.
987
+ - `identify(userId, traits)` - Identify a user and store their traits. All subsequent `track()` calls will include this user context.
804
988
  - `pageView(properties)` - Track a page view
805
989
  - `pageLeave(properties)` - Track a page leave event
806
- - `reset()` - Reset user session
990
+ - `reset()` - Reset user session, clearing userId and user traits
807
991
  - `updateContext(context)` - Update event context
808
992
 
809
993
  ### Server API
@@ -825,8 +1009,12 @@ const analytics = createServerAnalytics<AppEvents>({
825
1009
  ```
826
1010
 
827
1011
  #### `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
1012
+ - `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`.
1013
+ - `options.userId` - User ID for this event
1014
+ - `options.sessionId` - Session ID for this event
1015
+ - `options.user` - User context (email, traits) for this event
1016
+ - `options.context` - Additional event context (page, device, etc.)
1017
+ - `identify(userId, traits)` - Identify a user (sends to providers but doesn't persist on server)
830
1018
  - `pageView(properties, options)` - Track a page view
831
1019
  - `pageLeave(properties, options)` - Track a page leave event
832
1020
  - `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
- };