mbuzz 0.8.2 → 0.9.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.
@@ -1,647 +0,0 @@
1
- # mbuzz SDK v0.7.0 - Deterministic Session IDs
2
-
3
- > **⚠️ SUPERSEDED**: This spec has been replaced by server-side session resolution.
4
- > See: `multibuzz/lib/specs/1_visitor_session_tracking_spec.md`
5
- >
6
- > **What changed**: Instead of SDKs generating deterministic session IDs using time-buckets,
7
- > the server now handles all session resolution using IP + User-Agent fingerprinting with
8
- > a true 30-minute sliding window. SDKs no longer manage session cookies.
9
-
10
- **Status**: SUPERSEDED (2026-01-09)
11
- **Last Updated**: 2025-12-29
12
- **Breaking Change**: No (backward compatible)
13
- **Affects**: All SDKs (Ruby, Python, PHP, Node)
14
-
15
- ---
16
-
17
- ## Problem Statement
18
-
19
- ### The Race Condition
20
-
21
- When a page loads, multiple concurrent HTTP requests can hit the server before the first response sets cookies. Each request generates a new random session ID, creating duplicate sessions:
22
-
23
- ```
24
- User clicks link → Page starts loading
25
-
26
- Request 1 (HTML) ──────────────────▶ No cookies
27
- Server: session_id = random_1
28
-
29
- Request 2 (Turbo/fetch) ───────────▶ No cookies (response 1 not back yet)
30
- Server: session_id = random_2
31
-
32
- Request 3 (XHR) ───────────────────▶ No cookies
33
- Server: session_id = random_3
34
-
35
- Result: 3 different sessions created for the same page load!
36
- ```
37
-
38
- ### Production Evidence
39
-
40
- From Pet Resorts Australia (visitor #139):
41
- - 65 timestamps with multiple sessions created at the exact same second
42
- - 5 sessions created within 35ms, all with different session_ids
43
- - Same visitor_id preserved (2-year cookie works)
44
- - 94.8% of sessions have 0 page views (phantom sessions)
45
-
46
- ### Impact on Attribution
47
-
48
- 1. **Inflated "Direct" channel**: Internal navigations create new sessions classified as "direct"
49
- 2. **Broken Last Touch**: Real acquisition channels get overwritten by phantom "direct" sessions
50
- 3. **Skewed Metrics**: Average visits per conversion is 35.1 instead of ~1-2
51
-
52
- ---
53
-
54
- ## Solution: Deterministic Session ID Generation
55
-
56
- ### Core Concept
57
-
58
- Instead of generating random session IDs, generate **deterministic** IDs based on:
59
- 1. Visitor ID (from cookie - has 2-year expiry)
60
- 2. Time bucket (30-minute windows matching session timeout)
61
- 3. Request fingerprint (IP + User-Agent for new visitors)
62
-
63
- All concurrent requests will generate the **same session ID**.
64
-
65
- ### Algorithm
66
-
67
- ```
68
- IF visitor_id cookie exists:
69
- session_id = SHA256(visitor_id + time_bucket)[0:64]
70
- ELSE:
71
- fingerprint = SHA256(client_ip + user_agent)[0:32]
72
- session_id = SHA256(fingerprint + time_bucket)[0:64]
73
-
74
- WHERE:
75
- time_bucket = floor(unix_timestamp / SESSION_TIMEOUT_SECONDS)
76
- SESSION_TIMEOUT_SECONDS = 1800 (30 minutes)
77
- ```
78
-
79
- ### Why This Works
80
-
81
- | Scenario | Visitor Cookie | Result |
82
- |----------|----------------|--------|
83
- | Returning visitor, expired session | ✅ Exists | All concurrent requests get same session_id |
84
- | New visitor, first page load | ❌ Missing | All concurrent requests from same IP+UA get same session_id |
85
- | Active session | ✅ Exists + Session cookie | Cookie used directly (no generation needed) |
86
-
87
- ### Security Considerations
88
-
89
- 1. **Session IDs remain unpredictable**: SHA256 hash is not reversible
90
- 2. **No PII in session ID**: Only hashed values stored
91
- 3. **Time bucket prevents replay**: Old session IDs naturally expire
92
- 4. **IP+UA fingerprint is ephemeral**: Not stored, only used for generation
93
-
94
- ---
95
-
96
- ## Implementation Specification
97
-
98
- ### Constants
99
-
100
- ```
101
- SESSION_TIMEOUT_SECONDS = 1800 # 30 minutes
102
- SESSION_ID_LENGTH = 64 # Characters (256 bits as hex)
103
- FINGERPRINT_LENGTH = 32 # Characters for IP+UA hash
104
- HASH_ALGORITHM = "SHA256"
105
- ```
106
-
107
- ### New Module: `SessionIdGenerator`
108
-
109
- Each SDK must implement a `SessionIdGenerator` with these methods:
110
-
111
- #### `generate_deterministic(visitor_id, timestamp)`
112
-
113
- Generate session ID for **returning visitors** (have visitor cookie).
114
-
115
- ```
116
- Input:
117
- - visitor_id: string (64 hex chars)
118
- - timestamp: unix timestamp (seconds)
119
-
120
- Output:
121
- - session_id: string (64 hex chars)
122
-
123
- Algorithm:
124
- time_bucket = floor(timestamp / SESSION_TIMEOUT_SECONDS)
125
- raw = visitor_id + "_" + string(time_bucket)
126
- session_id = SHA256(raw).hexdigest()[0:64]
127
- ```
128
-
129
- #### `generate_from_fingerprint(client_ip, user_agent, timestamp)`
130
-
131
- Generate session ID for **new visitors** (no visitor cookie).
132
-
133
- ```
134
- Input:
135
- - client_ip: string (e.g., "192.168.1.1" or "2001:db8::1")
136
- - user_agent: string (browser user agent)
137
- - timestamp: unix timestamp (seconds)
138
-
139
- Output:
140
- - session_id: string (64 hex chars)
141
-
142
- Algorithm:
143
- fingerprint = SHA256(client_ip + "|" + user_agent).hexdigest()[0:32]
144
- time_bucket = floor(timestamp / SESSION_TIMEOUT_SECONDS)
145
- raw = fingerprint + "_" + string(time_bucket)
146
- session_id = SHA256(raw).hexdigest()[0:64]
147
- ```
148
-
149
- #### `generate_random()`
150
-
151
- Fallback for edge cases (no IP available, etc.).
152
-
153
- ```
154
- Output:
155
- - session_id: string (64 hex chars, cryptographically random)
156
- ```
157
-
158
- ---
159
-
160
- ## SDK Implementation Details
161
-
162
- ### Ruby (mbuzz-ruby)
163
-
164
- **File**: `lib/mbuzz/session/id_generator.rb`
165
-
166
- ```ruby
167
- # frozen_string_literal: true
168
-
169
- require "digest"
170
- require "securerandom"
171
-
172
- module Mbuzz
173
- module Session
174
- class IdGenerator
175
- SESSION_TIMEOUT_SECONDS = 1800
176
- SESSION_ID_LENGTH = 64
177
- FINGERPRINT_LENGTH = 32
178
-
179
- class << self
180
- def generate_deterministic(visitor_id:, timestamp: Time.now.to_i)
181
- time_bucket = timestamp / SESSION_TIMEOUT_SECONDS
182
- raw = "#{visitor_id}_#{time_bucket}"
183
- Digest::SHA256.hexdigest(raw)[0, SESSION_ID_LENGTH]
184
- end
185
-
186
- def generate_from_fingerprint(client_ip:, user_agent:, timestamp: Time.now.to_i)
187
- fingerprint = Digest::SHA256.hexdigest("#{client_ip}|#{user_agent}")[0, FINGERPRINT_LENGTH]
188
- time_bucket = timestamp / SESSION_TIMEOUT_SECONDS
189
- raw = "#{fingerprint}_#{time_bucket}"
190
- Digest::SHA256.hexdigest(raw)[0, SESSION_ID_LENGTH]
191
- end
192
-
193
- def generate_random
194
- SecureRandom.hex(32)
195
- end
196
- end
197
- end
198
- end
199
- end
200
- ```
201
-
202
- **File**: `lib/mbuzz/middleware/tracking.rb` (updated)
203
-
204
- ```ruby
205
- def build_request_context(request)
206
- visitor_id = visitor_id_from_cookie(request)
207
- is_new_visitor = visitor_id.nil?
208
- visitor_id ||= Visitor::Identifier.generate
209
-
210
- session_id = session_id_from_cookie(request) || generate_session_id(
211
- visitor_id: is_new_visitor ? nil : visitor_id,
212
- request: request
213
- )
214
-
215
- # ... rest unchanged
216
- end
217
-
218
- def generate_session_id(visitor_id:, request:)
219
- if visitor_id
220
- Session::IdGenerator.generate_deterministic(visitor_id: visitor_id)
221
- else
222
- Session::IdGenerator.generate_from_fingerprint(
223
- client_ip: client_ip(request),
224
- user_agent: user_agent(request)
225
- )
226
- end
227
- end
228
-
229
- def client_ip(request)
230
- request.env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip ||
231
- request.env["HTTP_X_REAL_IP"] ||
232
- request.ip ||
233
- "unknown"
234
- end
235
-
236
- def user_agent(request)
237
- request.user_agent || "unknown"
238
- end
239
- ```
240
-
241
- ---
242
-
243
- ### Node.js (mbuzz-node)
244
-
245
- **File**: `src/utils/sessionId.ts`
246
-
247
- ```typescript
248
- import { createHash, randomBytes } from 'node:crypto';
249
-
250
- const SESSION_TIMEOUT_SECONDS = 1800;
251
- const SESSION_ID_LENGTH = 64;
252
- const FINGERPRINT_LENGTH = 32;
253
-
254
- export function generateDeterministic(
255
- visitorId: string,
256
- timestamp: number = Math.floor(Date.now() / 1000)
257
- ): string {
258
- const timeBucket = Math.floor(timestamp / SESSION_TIMEOUT_SECONDS);
259
- const raw = `${visitorId}_${timeBucket}`;
260
- return createHash('sha256').update(raw).digest('hex').slice(0, SESSION_ID_LENGTH);
261
- }
262
-
263
- export function generateFromFingerprint(
264
- clientIp: string,
265
- userAgent: string,
266
- timestamp: number = Math.floor(Date.now() / 1000)
267
- ): string {
268
- const fingerprint = createHash('sha256')
269
- .update(`${clientIp}|${userAgent}`)
270
- .digest('hex')
271
- .slice(0, FINGERPRINT_LENGTH);
272
- const timeBucket = Math.floor(timestamp / SESSION_TIMEOUT_SECONDS);
273
- const raw = `${fingerprint}_${timeBucket}`;
274
- return createHash('sha256').update(raw).digest('hex').slice(0, SESSION_ID_LENGTH);
275
- }
276
-
277
- export function generateRandom(): string {
278
- return randomBytes(32).toString('hex');
279
- }
280
- ```
281
-
282
- **File**: `src/middleware/express.ts` (updated)
283
-
284
- ```typescript
285
- import { generateDeterministic, generateFromFingerprint, generateRandom } from '../utils/sessionId';
286
-
287
- const getClientIp = (req: Request): string => {
288
- const forwarded = req.headers['x-forwarded-for'];
289
- if (typeof forwarded === 'string') {
290
- return forwarded.split(',')[0].trim();
291
- }
292
- return req.ip || req.socket.remoteAddress || 'unknown';
293
- };
294
-
295
- const getUserAgent = (req: Request): string => {
296
- return req.headers['user-agent'] || 'unknown';
297
- };
298
-
299
- const getSessionId = (req: Request, visitorId: string | null): { id: string; isNew: boolean } => {
300
- const existing = req.cookies?.[SESSION_COOKIE];
301
- if (existing) {
302
- return { id: existing, isNew: false };
303
- }
304
-
305
- const id = visitorId
306
- ? generateDeterministic(visitorId)
307
- : generateFromFingerprint(getClientIp(req), getUserAgent(req));
308
-
309
- return { id, isNew: true };
310
- };
311
- ```
312
-
313
- ---
314
-
315
- ### Python (mbuzz-python)
316
-
317
- **File**: `src/mbuzz/utils/session_id.py`
318
-
319
- ```python
320
- """Deterministic session ID generation."""
321
-
322
- import hashlib
323
- import secrets
324
- import time
325
-
326
- SESSION_TIMEOUT_SECONDS = 1800
327
- SESSION_ID_LENGTH = 64
328
- FINGERPRINT_LENGTH = 32
329
-
330
-
331
- def generate_deterministic(visitor_id: str, timestamp: int | None = None) -> str:
332
- """Generate session ID for returning visitors."""
333
- if timestamp is None:
334
- timestamp = int(time.time())
335
- time_bucket = timestamp // SESSION_TIMEOUT_SECONDS
336
- raw = f"{visitor_id}_{time_bucket}"
337
- return hashlib.sha256(raw.encode()).hexdigest()[:SESSION_ID_LENGTH]
338
-
339
-
340
- def generate_from_fingerprint(
341
- client_ip: str,
342
- user_agent: str,
343
- timestamp: int | None = None
344
- ) -> str:
345
- """Generate session ID for new visitors using IP+UA fingerprint."""
346
- if timestamp is None:
347
- timestamp = int(time.time())
348
- fingerprint = hashlib.sha256(
349
- f"{client_ip}|{user_agent}".encode()
350
- ).hexdigest()[:FINGERPRINT_LENGTH]
351
- time_bucket = timestamp // SESSION_TIMEOUT_SECONDS
352
- raw = f"{fingerprint}_{time_bucket}"
353
- return hashlib.sha256(raw.encode()).hexdigest()[:SESSION_ID_LENGTH]
354
-
355
-
356
- def generate_random() -> str:
357
- """Generate random session ID (fallback)."""
358
- return secrets.token_hex(32)
359
- ```
360
-
361
- **File**: `src/mbuzz/middleware/flask.py` (updated)
362
-
363
- ```python
364
- from flask import request
365
- from ..utils.session_id import generate_deterministic, generate_from_fingerprint
366
-
367
-
368
- def _get_client_ip() -> str:
369
- """Get client IP from request headers."""
370
- forwarded = request.headers.get('X-Forwarded-For', '')
371
- if forwarded:
372
- return forwarded.split(',')[0].strip()
373
- return request.remote_addr or 'unknown'
374
-
375
-
376
- def _get_user_agent() -> str:
377
- """Get user agent from request."""
378
- return request.headers.get('User-Agent', 'unknown')
379
-
380
-
381
- def _get_or_create_session_id(visitor_id: str | None) -> str:
382
- """Get session ID from cookie or generate deterministic one."""
383
- existing = request.cookies.get(SESSION_COOKIE)
384
- if existing:
385
- return existing
386
-
387
- if visitor_id:
388
- return generate_deterministic(visitor_id)
389
- else:
390
- return generate_from_fingerprint(_get_client_ip(), _get_user_agent())
391
- ```
392
-
393
- ---
394
-
395
- ### PHP (mbuzz-php)
396
-
397
- **File**: `src/Mbuzz/SessionIdGenerator.php`
398
-
399
- ```php
400
- <?php
401
-
402
- declare(strict_types=1);
403
-
404
- namespace Mbuzz;
405
-
406
- final class SessionIdGenerator
407
- {
408
- private const SESSION_TIMEOUT_SECONDS = 1800;
409
- private const SESSION_ID_LENGTH = 64;
410
- private const FINGERPRINT_LENGTH = 32;
411
-
412
- /**
413
- * Generate session ID for returning visitors (have visitor cookie).
414
- */
415
- public static function generateDeterministic(
416
- string $visitorId,
417
- ?int $timestamp = null
418
- ): string {
419
- $timestamp = $timestamp ?? time();
420
- $timeBucket = intdiv($timestamp, self::SESSION_TIMEOUT_SECONDS);
421
- $raw = "{$visitorId}_{$timeBucket}";
422
- return substr(hash('sha256', $raw), 0, self::SESSION_ID_LENGTH);
423
- }
424
-
425
- /**
426
- * Generate session ID for new visitors using IP+UA fingerprint.
427
- */
428
- public static function generateFromFingerprint(
429
- string $clientIp,
430
- string $userAgent,
431
- ?int $timestamp = null
432
- ): string {
433
- $timestamp = $timestamp ?? time();
434
- $fingerprint = substr(
435
- hash('sha256', "{$clientIp}|{$userAgent}"),
436
- 0,
437
- self::FINGERPRINT_LENGTH
438
- );
439
- $timeBucket = intdiv($timestamp, self::SESSION_TIMEOUT_SECONDS);
440
- $raw = "{$fingerprint}_{$timeBucket}";
441
- return substr(hash('sha256', $raw), 0, self::SESSION_ID_LENGTH);
442
- }
443
-
444
- /**
445
- * Generate random session ID (fallback).
446
- */
447
- public static function generateRandom(): string
448
- {
449
- return bin2hex(random_bytes(32));
450
- }
451
- }
452
- ```
453
-
454
- **File**: `src/Mbuzz/Context.php` (updated to use new generator)
455
-
456
- ```php
457
- <?php
458
-
459
- // In the session ID resolution logic:
460
-
461
- private function resolveSessionId(?string $visitorId): string
462
- {
463
- $existing = $this->cookieManager->getSessionId();
464
- if ($existing !== null) {
465
- return $existing;
466
- }
467
-
468
- if ($visitorId !== null) {
469
- return SessionIdGenerator::generateDeterministic($visitorId);
470
- }
471
-
472
- return SessionIdGenerator::generateFromFingerprint(
473
- $this->getClientIp(),
474
- $this->getUserAgent()
475
- );
476
- }
477
-
478
- private function getClientIp(): string
479
- {
480
- return $_SERVER['HTTP_X_FORWARDED_FOR']
481
- ? explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]
482
- : ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown');
483
- }
484
-
485
- private function getUserAgent(): string
486
- {
487
- return $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
488
- }
489
- ```
490
-
491
- ---
492
-
493
- ## Server-Side Handling (multibuzz API)
494
-
495
- The API should handle idempotent session creation:
496
-
497
- ### Sessions Endpoint Update
498
-
499
- ```ruby
500
- # app/services/sessions/creation_service.rb
501
-
502
- def run
503
- return existing_session_result if session_exists?
504
-
505
- # Create new session...
506
- end
507
-
508
- def session_exists?
509
- # Check if session with this session_id already exists
510
- @existing_session = account.sessions.find_by(
511
- session_id: session_id,
512
- visitor: visitor
513
- )
514
- end
515
-
516
- def existing_session_result
517
- success_result(
518
- visitor_id: visitor.visitor_id,
519
- session_id: @existing_session.session_id,
520
- channel: @existing_session.channel,
521
- existing: true
522
- )
523
- end
524
- ```
525
-
526
- This ensures:
527
- 1. Multiple requests with same deterministic session_id don't create duplicates
528
- 2. First request's data (UTM, referrer) is preserved
529
- 3. Subsequent requests are no-ops
530
-
531
- ---
532
-
533
- ## Migration Path
534
-
535
- ### Backward Compatibility
536
-
537
- - Random session IDs still work (cookie-based sessions unaffected)
538
- - No changes to cookie format or names
539
- - No changes to API endpoints
540
- - Existing sessions continue to function
541
-
542
- ### Rollout Strategy
543
-
544
- 1. **Phase 1**: Deploy API changes (idempotent session creation)
545
- 2. **Phase 2**: Release SDK updates (v0.7.0 for all SDKs)
546
- 3. **Phase 3**: Monitor metrics for reduced duplicate sessions
547
-
548
- ### Version Matrix
549
-
550
- | SDK | Current | After |
551
- |-----|---------|-------|
552
- | mbuzz-ruby | 0.6.x | 0.7.0 |
553
- | mbuzz-node | 0.6.x | 0.7.0 |
554
- | mbuzz-python | 0.6.x | 0.7.0 |
555
- | mbuzz-php | 0.6.x | 0.7.0 |
556
-
557
- ---
558
-
559
- ## Testing Requirements
560
-
561
- ### Unit Tests
562
-
563
- Each SDK must test:
564
-
565
- 1. **Deterministic generation is consistent**
566
- ```
567
- generate_deterministic("visitor_abc", 1735500000) == generate_deterministic("visitor_abc", 1735500000)
568
- ```
569
-
570
- 2. **Different visitors get different IDs**
571
- ```
572
- generate_deterministic("visitor_a", t) != generate_deterministic("visitor_b", t)
573
- ```
574
-
575
- 3. **Time bucket boundaries work correctly**
576
- ```
577
- # Same 30-minute window = same ID
578
- generate_deterministic("v", 1735500000) == generate_deterministic("v", 1735500001)
579
-
580
- # Different window = different ID
581
- generate_deterministic("v", 1735500000) != generate_deterministic("v", 1735501800)
582
- ```
583
-
584
- 4. **Fingerprint generation is consistent**
585
- ```
586
- generate_from_fingerprint("1.2.3.4", "Mozilla/5.0", t) ==
587
- generate_from_fingerprint("1.2.3.4", "Mozilla/5.0", t)
588
- ```
589
-
590
- 5. **Different fingerprints get different IDs**
591
- ```
592
- generate_from_fingerprint("1.2.3.4", "UA1", t) !=
593
- generate_from_fingerprint("1.2.3.4", "UA2", t)
594
- ```
595
-
596
- ### Integration Tests
597
-
598
- 1. Concurrent requests from same visitor get same session
599
- 2. Session cookie is set correctly on first response
600
- 3. Subsequent requests use cookie (not regenerated)
601
-
602
- ---
603
-
604
- ## Metrics to Monitor
605
-
606
- After deployment, track:
607
-
608
- 1. **Sessions per visitor ratio** - Should decrease toward 1.0
609
- 2. **Duplicate session timestamps** - Should approach 0
610
- 3. **"Direct" channel percentage** - Should decrease if inflated
611
- 4. **Average visits per conversion** - Should normalize
612
-
613
- ### Success Criteria
614
-
615
- | Metric | Before | Target |
616
- |--------|--------|--------|
617
- | Sessions per new visitor | 2.0+ | < 1.2 |
618
- | Timestamps with duplicates | 65+ | < 5 |
619
- | Empty sessions (0 page views) | 94.8% | < 10% |
620
-
621
- ---
622
-
623
- ## Open Questions
624
-
625
- 1. **IPv6 handling**: Should we normalize IPv6 addresses before hashing?
626
- 2. **Proxy detection**: Should we try multiple headers (X-Real-IP, CF-Connecting-IP)?
627
- 3. **Bot detection**: Should known bot user agents bypass deterministic generation?
628
-
629
- ---
630
-
631
- ## Appendix: Hash Examples
632
-
633
- ```
634
- # Returning visitor
635
- visitor_id = "a1b2c3d4e5f6..."
636
- timestamp = 1735500000
637
- time_bucket = 1735500000 / 1800 = 964166
638
- raw = "a1b2c3d4e5f6..._964166"
639
- session_id = SHA256(raw)[0:64] = "7f8e9d0c1b2a..."
640
-
641
- # New visitor
642
- client_ip = "203.0.113.42"
643
- user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)..."
644
- fingerprint = SHA256("203.0.113.42|Mozilla/5.0...")[0:32] = "abc123..."
645
- raw = "abc123..._964166"
646
- session_id = SHA256(raw)[0:64] = "9e8d7c6b5a4..."
647
- ```