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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +37 -0
- data/lib/mbuzz/client/conversion_request.rb +6 -1
- data/lib/mbuzz/client/track_request.rb +8 -1
- data/lib/mbuzz/dropped_call.rb +41 -0
- data/lib/mbuzz/middleware/session_endpoint.rb +135 -0
- data/lib/mbuzz/railtie.rb +3 -0
- data/lib/mbuzz/version.rb +1 -1
- data/lib/mbuzz.rb +19 -4
- metadata +3 -11
- data/.DS_Store +0 -0
- data/CHECK_BUG.md +0 -168
- data/lib/.DS_Store +0 -0
- data/lib/specs/old/SPECIFICATION.md +0 -695
- data/lib/specs/old/conversions.md +0 -585
- data/lib/specs/old/event_ids_response.md +0 -346
- data/lib/specs/old/v0.2.0_breaking_changes.md +0 -519
- data/lib/specs/old/v2.0.0_sessions_upgrade.md +0 -265
- data/lib/specs/v0.5.0_four_call_model.md +0 -505
- data/lib/specs/v0.7.0_deterministic_sessions.md +0 -647
|
@@ -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
|
-
```
|