@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 +143 -94
- package/dist/datalyr.cjs.js +92 -6
- package/dist/datalyr.cjs.js.map +1 -1
- package/dist/datalyr.esm.js +92 -6
- package/dist/datalyr.esm.js.map +1 -1
- package/dist/datalyr.esm.min.js +1 -1
- package/dist/datalyr.esm.min.js.map +1 -1
- package/dist/datalyr.js +92 -6
- package/dist/datalyr.js.map +1 -1
- package/dist/datalyr.min.js +1 -1
- package/dist/datalyr.min.js.map +1 -1
- package/dist/identity.d.ts +4 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -504,127 +504,171 @@ export default function RootLayout({ children }) {
|
|
|
504
504
|
}
|
|
505
505
|
```
|
|
506
506
|
|
|
507
|
-
## Authentication &
|
|
507
|
+
## Authentication & Attribution Tracking
|
|
508
508
|
|
|
509
|
-
### ⚠️ Critical:
|
|
509
|
+
### ⚠️ Critical: Client-Side Identification for Attribution
|
|
510
510
|
|
|
511
|
-
When implementing authentication flows (login, signup, OAuth), you **MUST** call identify on the
|
|
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 -
|
|
517
|
-
|
|
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
|
-
//
|
|
535
|
-
await
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
556
|
-
async
|
|
557
|
-
const user = await
|
|
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
|
-
//
|
|
560
|
-
|
|
562
|
+
// Just return user data for client-side identify
|
|
563
|
+
res.json({
|
|
564
|
+
userId: user.id,
|
|
561
565
|
email: user.email,
|
|
562
|
-
|
|
563
|
-
source: 'signup'
|
|
566
|
+
name: user.name
|
|
564
567
|
});
|
|
565
|
-
|
|
566
|
-
redirect('/onboarding');
|
|
567
|
-
}
|
|
568
|
+
});
|
|
568
569
|
```
|
|
569
570
|
|
|
570
|
-
### Why
|
|
571
|
+
### Why Client-Side Identification Matters
|
|
571
572
|
|
|
572
|
-
1. **Attribution
|
|
573
|
-
2. **
|
|
574
|
-
3. **
|
|
575
|
-
4. **
|
|
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
|
|
580
|
+
Ensure you call identify **client-side** in these scenarios:
|
|
580
581
|
|
|
581
|
-
- [ ] **
|
|
582
|
-
- [ ] **
|
|
583
|
-
- [ ] **OAuth
|
|
584
|
-
- [ ] **
|
|
585
|
-
- [ ] **
|
|
586
|
-
- [ ] **
|
|
587
|
-
- [ ] **Session Restore**:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
600
|
-
await
|
|
601
|
-
|
|
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
|
-
//
|
|
630
|
+
// Create user, but DON'T identify
|
|
608
631
|
const newUser = await createUser(user);
|
|
609
|
-
await
|
|
610
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
624
|
-
- **Lost Attribution**:
|
|
625
|
-
- **Broken
|
|
626
|
-
- **
|
|
627
|
-
- **
|
|
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
|
|
683
|
-
|
|
726
|
+
### 4. ALWAYS Identify in the Browser (Not Server)
|
|
727
|
+
This is critical for attribution:
|
|
684
728
|
|
|
685
729
|
```javascript
|
|
686
|
-
//
|
|
730
|
+
// ✅ CORRECT - Browser identification
|
|
687
731
|
async function handleLogin(email, password) {
|
|
688
|
-
const
|
|
732
|
+
const response = await fetch('/api/login', {
|
|
733
|
+
method: 'POST',
|
|
734
|
+
body: JSON.stringify({ email, password })
|
|
735
|
+
});
|
|
689
736
|
|
|
690
|
-
|
|
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
|
-
//
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
|
package/dist/datalyr.cjs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @datalyr/web v1.0.
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|