@datalyr/web 1.0.5 → 1.0.7

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
@@ -504,127 +504,171 @@ export default function RootLayout({ children }) {
504
504
  }
505
505
  ```
506
506
 
507
- ## Authentication & Server-Side Tracking
507
+ ## Authentication & Attribution Tracking
508
508
 
509
- ### ⚠️ Critical: Server-Side Identification for Authentication
509
+ ### ⚠️ Critical: Client-Side Identification for Attribution
510
510
 
511
- When implementing authentication flows (login, signup, OAuth), you **MUST** call identify on the server-side before redirecting. Client-side identification during auth flows is unreliable and will cause attribution data loss.
511
+ When implementing authentication flows (login, signup, OAuth), you **MUST** call identify on the client-side (browser) to preserve attribution data. Server-side identification creates new sessions and loses all attribution context.
512
512
 
513
- #### The Problem
513
+ #### The Problem with Server-Side Identify
514
514
 
515
515
  ```javascript
516
- // ❌ WRONG - Client-side only (unreliable for auth flows)
517
- function LoginPage() {
518
- const handleLogin = async () => {
519
- await login(email, password);
520
- datalyr.identify(userId); // May not execute before redirect!
521
- router.push('/dashboard'); // User redirects, identify never completes
522
- };
523
- }
524
- ```
525
-
526
- #### The Solution
527
-
528
- ```javascript
529
- // ✅ CORRECT - Server-side identification
530
- // Next.js Server Action example
531
- async function loginAction(email: string, password: string) {
516
+ // ❌ WRONG - Server-side identify breaks attribution
517
+ app.post('/api/login', async (req, res) => {
532
518
  const user = await authenticate(email, password);
533
519
 
534
- // Critical: Identify BEFORE redirect
535
- await fetch('https://datalyr.com/api/v1/events', {
536
- method: 'POST',
537
- headers: {
538
- 'Authorization': `Bearer ${process.env.DATALYR_API_KEY}`,
539
- 'Content-Type': 'application/json'
540
- },
541
- body: JSON.stringify({
542
- event_name: '$identify',
543
- user_id: user.id,
544
- workspace_id: process.env.DATALYR_WORKSPACE_ID,
545
- properties: {
546
- email: user.email,
547
- source: 'login'
548
- }
549
- })
520
+ // This creates a NEW session without attribution data!
521
+ await serverAnalytics.identify({
522
+ userId: user.id,
523
+ traits: { email: user.email }
550
524
  });
551
525
 
552
- redirect('/dashboard');
526
+ res.json({ userId: user.id });
527
+ });
528
+ // Result: Lost UTM params, fbclid, gclid, referrer - all gone!
529
+ ```
530
+
531
+ #### The Correct Solution
532
+
533
+ ```javascript
534
+ // ✅ CORRECT - Client-side identification preserves attribution
535
+ // React/Next.js example
536
+ function LoginPage() {
537
+ const handleLogin = async () => {
538
+ // 1. Authenticate on server
539
+ const response = await fetch('/api/login', {
540
+ method: 'POST',
541
+ body: JSON.stringify({ email, password })
542
+ });
543
+
544
+ const { userId, email, name } = await response.json();
545
+
546
+ // 2. Identify in browser - this preserves attribution!
547
+ datalyr.identify(userId, {
548
+ email: email,
549
+ name: name,
550
+ login_method: 'email'
551
+ });
552
+
553
+ // 3. Then redirect
554
+ router.push('/dashboard');
555
+ };
553
556
  }
554
557
 
555
- // For signup
556
- async function signupAction(email: string, password: string) {
557
- const user = await createUser(email, password);
558
+ // Server endpoint - NO identify, just auth
559
+ app.post('/api/login', async (req, res) => {
560
+ const user = await authenticate(email, password);
558
561
 
559
- // Identify new user server-side
560
- await identifyUserServer(user.id, {
562
+ // Just return user data for client-side identify
563
+ res.json({
564
+ userId: user.id,
561
565
  email: user.email,
562
- created_at: new Date().toISOString(),
563
- source: 'signup'
566
+ name: user.name
564
567
  });
565
-
566
- redirect('/onboarding');
567
- }
568
+ });
568
569
  ```
569
570
 
570
- ### Why Server-Side Identification Matters
571
+ ### Why Client-Side Identification Matters
571
572
 
572
- 1. **Attribution Tracking**: Links anonymous visitors who clicked ads (with fbclid, gclid, etc.) to authenticated users
573
- 2. **Cross-Device Tracking**: Connects users across different devices and browsers
574
- 3. **Reliability**: Guarantees execution before redirects, unlike client-side
575
- 4. **OAuth Compatibility**: Works with complex OAuth redirect chains
573
+ 1. **Attribution Preserved**: UTM params, fbclid, gclid, referrer all stay linked to the user
574
+ 2. **Session Continuity**: Links anonymous session (with all its context) to identified user
575
+ 3. **Cookie Access**: Can read and maintain browser session data
576
+ 4. **Accurate Journey**: Tracks the complete anonymous identified user journey
576
577
 
577
578
  ### Implementation Checklist
578
579
 
579
- Ensure you call identify server-side in ALL these scenarios:
580
+ Ensure you call identify **client-side** in these scenarios:
580
581
 
581
- - [ ] **Email Signup**: Call identify when creating new accounts
582
- - [ ] **Email Login**: Call identify for returning users
583
- - [ ] **OAuth Signup**: Call identify in OAuth callback for new users
584
- - [ ] **OAuth Login**: Call identify in OAuth callback for existing users
585
- - [ ] **Magic Links**: Call identify when validating magic link tokens
586
- - [ ] **Password Reset**: Call identify after successful password reset
587
- - [ ] **Session Restore**: Call identify when validating existing sessions on app load
582
+ - [ ] **After Signup Success**: When account creation API returns success
583
+ - [ ] **After Login Success**: When authentication API returns success
584
+ - [ ] **After OAuth Redirect**: When returning from OAuth provider
585
+ - [ ] **After Magic Link Click**: When magic link is validated
586
+ - [ ] **On App Load**: If user session exists, re-identify
587
+ - [ ] **After Email Verification**: When user verifies their email
588
+ - [ ] **Session Restore**: When detecting existing authenticated session
588
589
 
589
590
  ### Example: Complete OAuth Implementation
590
591
 
591
592
  ```javascript
592
- // app/auth/callback/route.ts
593
+ // Client-side: After OAuth redirect
594
+ useEffect(() => {
595
+ // Check if we just came back from OAuth
596
+ if (window.location.search.includes('oauth=success')) {
597
+ // Get user data from your API/session
598
+ fetch('/api/me')
599
+ .then(res => res.json())
600
+ .then(user => {
601
+ // Identify in browser - preserves attribution!
602
+ datalyr.identify(user.id, {
603
+ email: user.email,
604
+ name: user.name,
605
+ auth_method: 'google',
606
+ signup_source: localStorage.getItem('signup_source') || 'organic'
607
+ });
608
+
609
+ // Track the appropriate event
610
+ if (user.isNewUser) {
611
+ datalyr.track('Signup Completed', { method: 'oauth' });
612
+ } else {
613
+ datalyr.track('Login Successful', { method: 'oauth' });
614
+ }
615
+ });
616
+ }
617
+ }, []);
618
+
619
+ // Server-side: OAuth callback - NO identify!
593
620
  export async function GET(request: Request) {
594
621
  const { user } = await validateOAuthCallback(request);
595
622
 
596
623
  const existingUser = await getUserByEmail(user.email);
597
624
 
598
625
  if (existingUser) {
599
- // Existing user logging in
600
- await identifyUserServer(existingUser.id, {
601
- email: existingUser.email,
602
- source: 'oauth_login',
603
- provider: 'google'
604
- });
605
- await trackEventServer('login', { method: 'oauth' });
626
+ // Set session, but DON'T identify
627
+ await createSession(existingUser.id);
628
+ return redirect('/dashboard?oauth=success');
606
629
  } else {
607
- // New user signing up
630
+ // Create user, but DON'T identify
608
631
  const newUser = await createUser(user);
609
- await identifyUserServer(newUser.id, {
610
- email: newUser.email,
611
- source: 'oauth_signup',
612
- provider: 'google'
613
- });
614
- await trackEventServer('signup', { method: 'oauth' });
632
+ await createSession(newUser.id);
633
+ return redirect('/dashboard?oauth=success&new=true');
615
634
  }
616
-
617
- return redirect('/dashboard');
618
635
  }
619
636
  ```
620
637
 
621
- ### What Happens Without Server-Side Identify
638
+ ### Server-Side Events (Different from Identify!)
639
+
640
+ Use server-side for **tracking events** with userId (not identify):
641
+
642
+ ```javascript
643
+ // ✅ CORRECT - Server-side events with userId
644
+ // Stripe webhook example
645
+ app.post('/webhook/stripe', async (req, res) => {
646
+ const event = stripe.webhooks.constructEvent(req.body, sig, secret);
647
+
648
+ if (event.type === 'checkout.session.completed') {
649
+ const session = event.data.object;
650
+
651
+ // Track purchase event with userId (NOT identify)
652
+ await analytics.track({
653
+ userId: session.client_reference_id, // Your user ID
654
+ event: 'Purchase Completed',
655
+ properties: {
656
+ amount: session.amount_total / 100,
657
+ currency: session.currency,
658
+ plan: session.metadata.plan
659
+ }
660
+ });
661
+ }
662
+ });
663
+ ```
664
+
665
+ ### What Happens with Server-Side Identify (Wrong!)
622
666
 
623
- Without proper server-side identification:
624
- - **Lost Attribution**: Ad clicks (fbclid, gclid) won't be associated with conversions
625
- - **Broken Cross-Device**: Users on multiple devices won't be linked
626
- - **Incomplete Funnels**: User journeys will appear fragmented
627
- - **Inaccurate Analytics**: Conversion rates and ROI calculations will be wrong
667
+ If you identify server-side:
668
+ - **Lost Attribution**: New session created, no UTM params or click IDs
669
+ - **Broken Journey**: Can't link anonymous browsing to identified user
670
+ - **Multiple Sessions**: Each server identify creates disconnected session
671
+ - **No Context**: Missing referrer, landing page, device info
628
672
 
629
673
  ## Best Practices
630
674
 
@@ -679,15 +723,20 @@ datalyr.track('Product Viewed', {
679
723
  });
680
724
  ```
681
725
 
682
- ### 4. Identify Users After Authentication
683
- Call identify as soon as you know who the user is:
726
+ ### 4. ALWAYS Identify in the Browser (Not Server)
727
+ This is critical for attribution:
684
728
 
685
729
  ```javascript
686
- // After login
730
+ // CORRECT - Browser identification
687
731
  async function handleLogin(email, password) {
688
- const user = await loginUser(email, password);
732
+ const response = await fetch('/api/login', {
733
+ method: 'POST',
734
+ body: JSON.stringify({ email, password })
735
+ });
689
736
 
690
- // Identify immediately after login
737
+ const user = await response.json();
738
+
739
+ // Identify in browser - preserves attribution!
691
740
  datalyr.identify(user.id, {
692
741
  email: user.email,
693
742
  name: user.name,
@@ -695,13 +744,13 @@ async function handleLogin(email, password) {
695
744
  });
696
745
  }
697
746
 
698
- // After signup
699
- async function handleSignup(data) {
700
- const user = await createUser(data);
747
+ // WRONG - Server identification
748
+ // This breaks attribution tracking!
749
+ app.post('/api/login', async (req, res) => {
750
+ const user = await authenticate(req.body);
701
751
 
702
- datalyr.identify(user.id, {
703
- email: data.email,
704
- name: data.name,
752
+ // DON'T DO THIS - Creates new session
753
+ await serverAnalytics.identify(user.id, {
705
754
  signup_date: new Date().toISOString()
706
755
  });
707
756
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datalyr/web v1.0.5
2
+ * @datalyr/web v1.0.7
3
3
  * Datalyr Web SDK - Modern attribution tracking for web applications
4
4
  * (c) 2025 Datalyr Inc.
5
5
  * Released under the MIT License
@@ -163,7 +163,16 @@ class CookieStorage {
163
163
  const value = `; ${document.cookie}`;
164
164
  const parts = value.split(`; ${name}=`);
165
165
  if (parts.length === 2) {
166
- return ((_a = parts.pop()) === null || _a === void 0 ? void 0 : _a.split(';').shift()) || null;
166
+ const rawValue = ((_a = parts.pop()) === null || _a === void 0 ? void 0 : _a.split(';').shift()) || null;
167
+ if (rawValue) {
168
+ try {
169
+ return decodeURIComponent(rawValue);
170
+ }
171
+ catch (_b) {
172
+ // Return raw value if decoding fails (backwards compatibility)
173
+ return rawValue;
174
+ }
175
+ }
167
176
  }
168
177
  return null;
169
178
  }
@@ -397,6 +406,33 @@ function isGlobalPrivacyControlEnabled() {
397
406
  return navigator.globalPrivacyControl === true ||
398
407
  window.globalPrivacyControl === true;
399
408
  }
409
+ /**
410
+ * Get root domain for cross-subdomain tracking
411
+ */
412
+ function getRootDomain() {
413
+ const hostname = window.location.hostname;
414
+ // Handle localhost and IP addresses
415
+ if (hostname === 'localhost' ||
416
+ hostname.match(/^[0-9]{1,3}\./) || // IPv4
417
+ hostname.match(/^\[?[0-9a-fA-F:]+\]?$/)) { // IPv6
418
+ return hostname;
419
+ }
420
+ // Get root domain (last two parts: example.com)
421
+ const parts = hostname.split('.');
422
+ if (parts.length >= 2) {
423
+ // Handle .co.uk, .com.au, etc
424
+ const tld = parts[parts.length - 1];
425
+ const sld = parts[parts.length - 2];
426
+ // Common two-part TLDs
427
+ const twoPartTlds = ['co.uk', 'com.au', 'co.nz', 'co.jp', 'co.in', 'co.za'];
428
+ const lastTwo = `${sld}.${tld}`;
429
+ if (twoPartTlds.includes(lastTwo) && parts.length >= 3) {
430
+ return '.' + parts.slice(-3).join('.');
431
+ }
432
+ return '.' + parts.slice(-2).join('.');
433
+ }
434
+ return hostname;
435
+ }
400
436
  /**
401
437
  * Get referrer data
402
438
  */
@@ -460,13 +496,61 @@ class IdentityManager {
460
496
  * Get or create anonymous ID (device/browser identifier)
461
497
  */
462
498
  getOrCreateAnonymousId() {
463
- let anonymousId = storage.get('dl_anonymous_id');
464
- if (!anonymousId) {
465
- anonymousId = `anon_${generateUUID()}`;
499
+ // 1. Check root domain cookie first (works across subdomains)
500
+ let anonymousId = cookies.get('__dl_visitor_id');
501
+ if (anonymousId) {
502
+ // Found in cookie - sync to localStorage
466
503
  storage.set('dl_anonymous_id', anonymousId);
467
- }
504
+ return anonymousId;
505
+ }
506
+ // 2. Check localStorage (fallback for cookie issues)
507
+ anonymousId = storage.get('dl_anonymous_id');
508
+ if (anonymousId) {
509
+ // Found in localStorage - set root domain cookie
510
+ this.setRootDomainCookie('__dl_visitor_id', anonymousId);
511
+ return anonymousId;
512
+ }
513
+ // 3. Generate new ID
514
+ anonymousId = `anon_${generateUUID()}`;
515
+ // 4. Store in both cookie (primary) and localStorage (backup)
516
+ this.setRootDomainCookie('__dl_visitor_id', anonymousId);
517
+ storage.set('dl_anonymous_id', anonymousId);
468
518
  return anonymousId;
469
519
  }
520
+ /**
521
+ * Set a root domain cookie for cross-subdomain tracking
522
+ */
523
+ setRootDomainCookie(name, value) {
524
+ try {
525
+ const rootDomain = getRootDomain();
526
+ const secure = location.protocol === 'https:' ? '; Secure' : '';
527
+ const encodedValue = encodeURIComponent(value);
528
+ // Set cookie with root domain, 1 year expiry
529
+ document.cookie = `${name}=${encodedValue}; domain=${rootDomain}; path=/; max-age=31536000; SameSite=Lax${secure}`;
530
+ // Verify cookie was set successfully (cookies.get already decodes)
531
+ const verifyValue = cookies.get(name);
532
+ if (verifyValue === value) {
533
+ console.log(`[Datalyr] Set root domain cookie: ${name} on domain: ${rootDomain}`);
534
+ }
535
+ else {
536
+ // Fallback: try without domain (current subdomain only)
537
+ document.cookie = `${name}=${encodedValue}; path=/; max-age=31536000; SameSite=Lax${secure}`;
538
+ console.log(`[Datalyr] Set cookie without domain (fallback): ${name}`);
539
+ }
540
+ }
541
+ catch (e) {
542
+ console.error('[Datalyr] Error setting root domain cookie:', e);
543
+ // Still try to set without domain as fallback
544
+ try {
545
+ const secure = location.protocol === 'https:' ? '; Secure' : '';
546
+ const encodedValue = encodeURIComponent(value);
547
+ document.cookie = `${name}=${encodedValue}; path=/; max-age=31536000; SameSite=Lax${secure}`;
548
+ }
549
+ catch (fallbackError) {
550
+ console.error('[Datalyr] Failed to set cookie even without domain:', fallbackError);
551
+ }
552
+ }
553
+ }
470
554
  /**
471
555
  * Get stored user ID from previous session
472
556
  */
@@ -560,6 +644,8 @@ class IdentityManager {
560
644
  // Generate new anonymous ID for privacy
561
645
  this.anonymousId = `anon_${generateUUID()}`;
562
646
  storage.set('dl_anonymous_id', this.anonymousId);
647
+ // Update root domain cookie with new ID
648
+ this.setRootDomainCookie('__dl_visitor_id', this.anonymousId);
563
649
  }
564
650
  /**
565
651
  * Get all identity fields for event payload