mbuzz 0.2.0__tar.gz → 0.7.3__tar.gz
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.
- mbuzz-0.7.3/CHANGELOG.md +18 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/PKG-INFO +1 -1
- {mbuzz-0.2.0 → mbuzz-0.7.3}/pyproject.toml +1 -1
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/__init__.py +2 -8
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/api.py +1 -1
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/client/conversion.py +26 -5
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/client/track.py +16 -13
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/context.py +3 -3
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/cookies.py +1 -3
- mbuzz-0.7.3/src/mbuzz/middleware/flask.py +162 -0
- mbuzz-0.7.3/src/mbuzz/utils/__init__.py +6 -0
- mbuzz-0.7.3/src/mbuzz/utils/fingerprint.py +12 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_client.py +98 -61
- {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_context.py +50 -15
- mbuzz-0.7.3/tests/test_fingerprint.py +33 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_middleware.py +242 -88
- mbuzz-0.7.3/uv.lock +1227 -0
- mbuzz-0.2.0/src/mbuzz/client/session.py +0 -38
- mbuzz-0.2.0/src/mbuzz/middleware/flask.py +0 -141
- mbuzz-0.2.0/src/mbuzz/utils/__init__.py +0 -5
- mbuzz-0.2.0/src/mbuzz/utils/session_id.py +0 -39
- mbuzz-0.2.0/tests/test_session_id.py +0 -146
- {mbuzz-0.2.0 → mbuzz-0.7.3}/.gitignore +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/README.md +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/client/__init__.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/client/identify.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/config.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/middleware/__init__.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/utils/identifier.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/__init__.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_api.py +0 -0
- {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_config.py +0 -0
mbuzz-0.7.3/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.7.3 (2026-02-03)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Navigation-aware session creation** — middleware now only creates server-side sessions for real page navigations, filtering out Turbo frames, htmx partials, fetch/XHR, prefetch, and other sub-requests. Uses browser-enforced `Sec-Fetch-*` headers as the primary signal with a framework-specific blacklist fallback for old browsers.
|
|
8
|
+
- `device_fingerprint()` utility — computes `SHA256(ip|user_agent)[0:32]`, matching the server-side fingerprint for session deduplication.
|
|
9
|
+
- Async session creation via `POST /sessions` — fire-and-forget background thread on real navigations.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **5x visit count inflation** caused by concurrent sub-requests (Turbo frames, htmx) each creating separate sessions on first page load.
|
|
14
|
+
|
|
15
|
+
## 0.7.0 (2026-01-15)
|
|
16
|
+
|
|
17
|
+
- Initial release with Flask middleware, visitor cookie management, event tracking, user identification, and conversion tracking.
|
|
18
|
+
- Session cookie removed — server handles session resolution via device fingerprint.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Mbuzz - Multi-touch attribution SDK for Python."""
|
|
2
|
+
# NOTE: Session ID removed in 0.7.0 - server handles session resolution
|
|
2
3
|
|
|
3
4
|
from typing import Any, Dict, Optional, Union
|
|
4
5
|
|
|
@@ -8,7 +9,7 @@ from .client.track import track, TrackResult
|
|
|
8
9
|
from .client.identify import identify
|
|
9
10
|
from .client.conversion import conversion, ConversionResult
|
|
10
11
|
|
|
11
|
-
__version__ = "0.
|
|
12
|
+
__version__ = "0.7.3"
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def init(
|
|
@@ -61,12 +62,6 @@ def visitor_id() -> Optional[str]:
|
|
|
61
62
|
return ctx.visitor_id if ctx else None
|
|
62
63
|
|
|
63
64
|
|
|
64
|
-
def session_id() -> Optional[str]:
|
|
65
|
-
"""Get current session ID from context."""
|
|
66
|
-
ctx = get_context()
|
|
67
|
-
return ctx.session_id if ctx else None
|
|
68
|
-
|
|
69
|
-
|
|
70
65
|
def user_id() -> Optional[str]:
|
|
71
66
|
"""Get current user ID from context."""
|
|
72
67
|
ctx = get_context()
|
|
@@ -79,7 +74,6 @@ __all__ = [
|
|
|
79
74
|
"conversion",
|
|
80
75
|
"identify",
|
|
81
76
|
"visitor_id",
|
|
82
|
-
"session_id",
|
|
83
77
|
"user_id",
|
|
84
78
|
"TrackResult",
|
|
85
79
|
"ConversionResult",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Conversion request for tracking conversions."""
|
|
2
|
+
# NOTE: Session ID removed in 0.7.0 - server handles session resolution
|
|
2
3
|
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from typing import Any, Dict, Optional, Union
|
|
@@ -26,6 +27,9 @@ def conversion(
|
|
|
26
27
|
is_acquisition: bool = False,
|
|
27
28
|
inherit_acquisition: bool = False,
|
|
28
29
|
properties: Optional[Dict[str, Any]] = None,
|
|
30
|
+
ip: Optional[str] = None,
|
|
31
|
+
user_agent: Optional[str] = None,
|
|
32
|
+
identifier: Optional[Dict[str, str]] = None,
|
|
29
33
|
) -> ConversionResult:
|
|
30
34
|
"""Track a conversion.
|
|
31
35
|
|
|
@@ -39,6 +43,9 @@ def conversion(
|
|
|
39
43
|
is_acquisition: Whether this is a customer acquisition
|
|
40
44
|
inherit_acquisition: Whether to inherit acquisition from previous conversion
|
|
41
45
|
properties: Additional conversion properties
|
|
46
|
+
ip: Client IP address for server-side session resolution
|
|
47
|
+
user_agent: Client user agent for server-side session resolution
|
|
48
|
+
identifier: Cross-device identifier (email, user_id, etc.)
|
|
42
49
|
|
|
43
50
|
Returns:
|
|
44
51
|
ConversionResult with success status, conversion ID, and attribution data
|
|
@@ -47,22 +54,36 @@ def conversion(
|
|
|
47
54
|
|
|
48
55
|
visitor_id = visitor_id or (ctx.visitor_id if ctx else None)
|
|
49
56
|
user_id = user_id or (ctx.user_id if ctx else None)
|
|
57
|
+
ip = ip or (ctx.ip if ctx else None)
|
|
58
|
+
user_agent = user_agent or (ctx.user_agent if ctx else None)
|
|
50
59
|
|
|
51
60
|
if not visitor_id and not user_id:
|
|
52
61
|
return ConversionResult(success=False)
|
|
53
62
|
|
|
54
|
-
payload = {
|
|
63
|
+
payload: Dict[str, Any] = {
|
|
55
64
|
"conversion_type": conversion_type,
|
|
56
|
-
"visitor_id": visitor_id,
|
|
57
|
-
"user_id": str(user_id) if user_id else None,
|
|
58
|
-
"event_id": event_id,
|
|
59
|
-
"revenue": revenue,
|
|
60
65
|
"currency": currency,
|
|
61
66
|
"is_acquisition": is_acquisition,
|
|
62
67
|
"inherit_acquisition": inherit_acquisition,
|
|
63
68
|
"properties": properties or {},
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
# Only include non-None values
|
|
72
|
+
if visitor_id:
|
|
73
|
+
payload["visitor_id"] = visitor_id
|
|
74
|
+
if user_id:
|
|
75
|
+
payload["user_id"] = str(user_id)
|
|
76
|
+
if event_id:
|
|
77
|
+
payload["event_id"] = event_id
|
|
78
|
+
if revenue is not None:
|
|
79
|
+
payload["revenue"] = revenue
|
|
80
|
+
if ip:
|
|
81
|
+
payload["ip"] = ip
|
|
82
|
+
if user_agent:
|
|
83
|
+
payload["user_agent"] = user_agent
|
|
84
|
+
if identifier:
|
|
85
|
+
payload["identifier"] = identifier
|
|
86
|
+
|
|
66
87
|
response = post_with_response("/conversions", payload)
|
|
67
88
|
if not response:
|
|
68
89
|
return ConversionResult(success=False)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Track request for event tracking."""
|
|
2
|
+
# NOTE: Session ID removed in 0.7.0 - server handles session resolution
|
|
2
3
|
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from datetime import datetime, timezone
|
|
@@ -16,7 +17,6 @@ class TrackResult:
|
|
|
16
17
|
event_id: Optional[str] = None
|
|
17
18
|
event_type: Optional[str] = None
|
|
18
19
|
visitor_id: Optional[str] = None
|
|
19
|
-
session_id: Optional[str] = None
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
@dataclass
|
|
@@ -25,15 +25,15 @@ class TrackOptions:
|
|
|
25
25
|
|
|
26
26
|
event_type: str
|
|
27
27
|
visitor_id: Optional[str] = None
|
|
28
|
-
session_id: Optional[str] = None
|
|
29
28
|
user_id: Optional[str] = None
|
|
30
29
|
properties: Optional[Dict[str, Any]] = None
|
|
31
30
|
ip: Optional[str] = None
|
|
32
31
|
user_agent: Optional[str] = None
|
|
32
|
+
identifier: Optional[Dict[str, str]] = None
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def _resolve_ids(options: TrackOptions) -> TrackOptions:
|
|
36
|
-
"""Resolve visitor/
|
|
36
|
+
"""Resolve visitor/user IDs and ip/user_agent from context if not provided."""
|
|
37
37
|
ctx = get_context()
|
|
38
38
|
if not ctx:
|
|
39
39
|
return options
|
|
@@ -41,11 +41,11 @@ def _resolve_ids(options: TrackOptions) -> TrackOptions:
|
|
|
41
41
|
return TrackOptions(
|
|
42
42
|
event_type=options.event_type,
|
|
43
43
|
visitor_id=options.visitor_id or ctx.visitor_id,
|
|
44
|
-
session_id=options.session_id or ctx.session_id,
|
|
45
44
|
user_id=options.user_id or ctx.user_id,
|
|
46
45
|
properties=options.properties,
|
|
47
46
|
ip=options.ip or ctx.ip,
|
|
48
47
|
user_agent=options.user_agent or ctx.user_agent,
|
|
48
|
+
identifier=options.identifier,
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
|
|
@@ -66,19 +66,23 @@ def _validate(options: TrackOptions) -> bool:
|
|
|
66
66
|
|
|
67
67
|
def _build_payload(options: TrackOptions, properties: Dict[str, Any]) -> Dict[str, Any]:
|
|
68
68
|
"""Build API payload from options."""
|
|
69
|
-
event = {
|
|
69
|
+
event: Dict[str, Any] = {
|
|
70
70
|
"event_type": options.event_type,
|
|
71
|
-
"visitor_id": options.visitor_id,
|
|
72
|
-
"session_id": options.session_id,
|
|
73
|
-
"user_id": options.user_id,
|
|
74
71
|
"properties": properties,
|
|
75
72
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
76
73
|
}
|
|
77
|
-
|
|
74
|
+
|
|
75
|
+
# Only include non-None values
|
|
76
|
+
if options.visitor_id:
|
|
77
|
+
event["visitor_id"] = options.visitor_id
|
|
78
|
+
if options.user_id:
|
|
79
|
+
event["user_id"] = options.user_id
|
|
78
80
|
if options.ip:
|
|
79
81
|
event["ip"] = options.ip
|
|
80
82
|
if options.user_agent:
|
|
81
83
|
event["user_agent"] = options.user_agent
|
|
84
|
+
if options.identifier:
|
|
85
|
+
event["identifier"] = options.identifier
|
|
82
86
|
|
|
83
87
|
return {"events": [event]}
|
|
84
88
|
|
|
@@ -94,29 +98,28 @@ def _parse_response(response: Optional[Dict[str, Any]], options: TrackOptions) -
|
|
|
94
98
|
event_id=event.get("id"),
|
|
95
99
|
event_type=options.event_type,
|
|
96
100
|
visitor_id=options.visitor_id,
|
|
97
|
-
session_id=options.session_id,
|
|
98
101
|
)
|
|
99
102
|
|
|
100
103
|
|
|
101
104
|
def track(
|
|
102
105
|
event_type: str,
|
|
103
106
|
visitor_id: Optional[str] = None,
|
|
104
|
-
session_id: Optional[str] = None,
|
|
105
107
|
user_id: Optional[str] = None,
|
|
106
108
|
properties: Optional[Dict[str, Any]] = None,
|
|
107
109
|
ip: Optional[str] = None,
|
|
108
110
|
user_agent: Optional[str] = None,
|
|
111
|
+
identifier: Optional[Dict[str, str]] = None,
|
|
109
112
|
) -> TrackResult:
|
|
110
113
|
"""Track an event.
|
|
111
114
|
|
|
112
115
|
Args:
|
|
113
116
|
event_type: Type of event (e.g., "page_view", "button_click")
|
|
114
117
|
visitor_id: Visitor ID (uses context if not provided)
|
|
115
|
-
session_id: Session ID (uses context if not provided)
|
|
116
118
|
user_id: User ID (uses context if not provided)
|
|
117
119
|
properties: Additional event properties
|
|
118
120
|
ip: Client IP address for server-side session resolution
|
|
119
121
|
user_agent: Client user agent for server-side session resolution
|
|
122
|
+
identifier: Cross-device identifier (email, user_id, etc.)
|
|
120
123
|
|
|
121
124
|
Returns:
|
|
122
125
|
TrackResult with success status and event details
|
|
@@ -124,11 +127,11 @@ def track(
|
|
|
124
127
|
options = TrackOptions(
|
|
125
128
|
event_type=event_type,
|
|
126
129
|
visitor_id=visitor_id,
|
|
127
|
-
session_id=session_id,
|
|
128
130
|
user_id=user_id,
|
|
129
131
|
properties=properties,
|
|
130
132
|
ip=ip,
|
|
131
133
|
user_agent=user_agent,
|
|
134
|
+
identifier=identifier,
|
|
132
135
|
)
|
|
133
136
|
|
|
134
137
|
options = _resolve_ids(options)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Request context management using contextvars."""
|
|
2
|
+
# NOTE: Session ID removed in 0.7.0 - server handles session resolution
|
|
2
3
|
|
|
3
4
|
from contextvars import ContextVar
|
|
4
5
|
from dataclasses import dataclass
|
|
@@ -10,12 +11,11 @@ class RequestContext:
|
|
|
10
11
|
"""Holds request-scoped data for tracking."""
|
|
11
12
|
|
|
12
13
|
visitor_id: str
|
|
13
|
-
|
|
14
|
+
ip: str
|
|
15
|
+
user_agent: str
|
|
14
16
|
user_id: Optional[str] = None
|
|
15
17
|
url: Optional[str] = None
|
|
16
18
|
referrer: Optional[str] = None
|
|
17
|
-
ip: Optional[str] = None
|
|
18
|
-
user_agent: Optional[str] = None
|
|
19
19
|
|
|
20
20
|
def enrich_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]:
|
|
21
21
|
"""Add url and referrer to properties if not already present."""
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Cookie constants for mbuzz SDK."""
|
|
2
|
+
# NOTE: Session cookie removed in 0.7.0 - server handles session resolution
|
|
2
3
|
|
|
3
4
|
VISITOR_COOKIE = "_mbuzz_vid"
|
|
4
|
-
SESSION_COOKIE = "_mbuzz_sid"
|
|
5
|
-
|
|
6
5
|
VISITOR_MAX_AGE = 63072000 # 2 years in seconds
|
|
7
|
-
SESSION_MAX_AGE = 1800 # 30 minutes in seconds
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Flask middleware for mbuzz tracking."""
|
|
2
|
+
# NOTE: Session cookie removed in 0.7.0 - server handles session resolution
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from flask import Flask, request, g, Response
|
|
10
|
+
|
|
11
|
+
from ..api import post
|
|
12
|
+
from ..config import config
|
|
13
|
+
from ..context import RequestContext, set_context, clear_context
|
|
14
|
+
from ..cookies import VISITOR_COOKIE, VISITOR_MAX_AGE
|
|
15
|
+
from ..utils.fingerprint import device_fingerprint
|
|
16
|
+
from ..utils.identifier import generate_id
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def should_create_session() -> bool:
|
|
20
|
+
"""Determine whether this request is a real page navigation.
|
|
21
|
+
|
|
22
|
+
Primary signal: Sec-Fetch-* headers (modern browsers, unforgeable).
|
|
23
|
+
Fallback: blacklist known sub-request framework headers (old browsers/bots).
|
|
24
|
+
"""
|
|
25
|
+
mode = request.headers.get("Sec-Fetch-Mode")
|
|
26
|
+
dest = request.headers.get("Sec-Fetch-Dest")
|
|
27
|
+
|
|
28
|
+
if mode:
|
|
29
|
+
return (
|
|
30
|
+
mode == "navigate"
|
|
31
|
+
and dest == "document"
|
|
32
|
+
and not request.headers.get("Sec-Purpose")
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Fallback for old browsers / bots: blacklist known sub-requests
|
|
36
|
+
return (
|
|
37
|
+
not request.headers.get("Turbo-Frame")
|
|
38
|
+
and not request.headers.get("HX-Request")
|
|
39
|
+
and not request.headers.get("X-Up-Version")
|
|
40
|
+
and request.headers.get("X-Requested-With") != "XMLHttpRequest"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def init_app(app: Flask) -> None:
|
|
45
|
+
"""Initialize mbuzz tracking for Flask app."""
|
|
46
|
+
|
|
47
|
+
@app.before_request
|
|
48
|
+
def before_request():
|
|
49
|
+
if _should_skip():
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
visitor_id = _get_or_create_visitor_id()
|
|
53
|
+
ip = _get_client_ip()
|
|
54
|
+
user_agent = _get_user_agent()
|
|
55
|
+
|
|
56
|
+
_set_request_context(visitor_id, ip, user_agent)
|
|
57
|
+
_store_in_g(visitor_id)
|
|
58
|
+
|
|
59
|
+
if should_create_session():
|
|
60
|
+
_create_session_async(
|
|
61
|
+
visitor_id, request.url, request.referrer, ip, user_agent
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@app.after_request
|
|
65
|
+
def after_request(response: Response) -> Response:
|
|
66
|
+
if not hasattr(g, "mbuzz_visitor_id"):
|
|
67
|
+
return response
|
|
68
|
+
|
|
69
|
+
_set_cookies(response)
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
@app.teardown_request
|
|
73
|
+
def teardown_request(exception=None):
|
|
74
|
+
clear_context()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _should_skip() -> bool:
|
|
78
|
+
"""Check if request should skip tracking."""
|
|
79
|
+
if not config._initialized or not config.enabled:
|
|
80
|
+
return True
|
|
81
|
+
if config.should_skip_path(request.path):
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_or_create_visitor_id() -> str:
|
|
87
|
+
"""Get visitor ID from cookie or generate new one."""
|
|
88
|
+
return request.cookies.get(VISITOR_COOKIE) or generate_id()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_client_ip() -> str:
|
|
92
|
+
"""Get client IP from request headers."""
|
|
93
|
+
forwarded = request.headers.get("X-Forwarded-For", "")
|
|
94
|
+
if forwarded:
|
|
95
|
+
return forwarded.split(",")[0].strip()
|
|
96
|
+
return request.remote_addr or "unknown"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_user_agent() -> str:
|
|
100
|
+
"""Get user agent from request."""
|
|
101
|
+
return request.headers.get("User-Agent", "unknown")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _set_request_context(visitor_id: str, ip: str, user_agent: str) -> None:
|
|
105
|
+
"""Set request context for tracking calls."""
|
|
106
|
+
ctx = RequestContext(
|
|
107
|
+
visitor_id=visitor_id,
|
|
108
|
+
ip=ip,
|
|
109
|
+
user_agent=user_agent,
|
|
110
|
+
user_id=None,
|
|
111
|
+
url=request.url,
|
|
112
|
+
referrer=request.referrer,
|
|
113
|
+
)
|
|
114
|
+
set_context(ctx)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _store_in_g(visitor_id: str) -> None:
|
|
118
|
+
"""Store tracking IDs in Flask g object for after_request."""
|
|
119
|
+
g.mbuzz_visitor_id = visitor_id
|
|
120
|
+
g.mbuzz_is_new_visitor = VISITOR_COOKIE not in request.cookies
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _create_session_async(
|
|
124
|
+
visitor_id: str,
|
|
125
|
+
url: str,
|
|
126
|
+
referrer: Optional[str],
|
|
127
|
+
ip: str,
|
|
128
|
+
user_agent: str,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Fire-and-forget session creation via background thread.
|
|
131
|
+
|
|
132
|
+
All data is captured before the thread starts — no request-object
|
|
133
|
+
access inside the thread (it would be invalid after the response).
|
|
134
|
+
"""
|
|
135
|
+
payload = {
|
|
136
|
+
"session": {
|
|
137
|
+
"visitor_id": visitor_id,
|
|
138
|
+
"session_id": str(uuid.uuid4()),
|
|
139
|
+
"url": url,
|
|
140
|
+
"referrer": referrer,
|
|
141
|
+
"device_fingerprint": device_fingerprint(ip, user_agent),
|
|
142
|
+
"started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
threading.Thread(
|
|
147
|
+
target=post, args=("/sessions", payload), daemon=True
|
|
148
|
+
).start()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _set_cookies(response: Response) -> None:
|
|
152
|
+
"""Set visitor cookie on response."""
|
|
153
|
+
secure = request.is_secure
|
|
154
|
+
|
|
155
|
+
response.set_cookie(
|
|
156
|
+
VISITOR_COOKIE,
|
|
157
|
+
g.mbuzz_visitor_id,
|
|
158
|
+
max_age=VISITOR_MAX_AGE,
|
|
159
|
+
httponly=True,
|
|
160
|
+
samesite="Lax",
|
|
161
|
+
secure=secure,
|
|
162
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Device fingerprint generation — matches server-side SHA256(ip|user_agent)[0:32]."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def device_fingerprint(ip: str, user_agent: str) -> str:
|
|
7
|
+
"""Compute a device fingerprint from IP and User-Agent.
|
|
8
|
+
|
|
9
|
+
Produces a 32-char hex string identical to the server-side computation
|
|
10
|
+
and the Ruby/Node SDKs.
|
|
11
|
+
"""
|
|
12
|
+
return hashlib.sha256(f"{ip}|{user_agent}".encode()).hexdigest()[:32]
|