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.
Files changed (32) hide show
  1. mbuzz-0.7.3/CHANGELOG.md +18 -0
  2. {mbuzz-0.2.0 → mbuzz-0.7.3}/PKG-INFO +1 -1
  3. {mbuzz-0.2.0 → mbuzz-0.7.3}/pyproject.toml +1 -1
  4. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/__init__.py +2 -8
  5. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/api.py +1 -1
  6. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/client/conversion.py +26 -5
  7. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/client/track.py +16 -13
  8. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/context.py +3 -3
  9. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/cookies.py +1 -3
  10. mbuzz-0.7.3/src/mbuzz/middleware/flask.py +162 -0
  11. mbuzz-0.7.3/src/mbuzz/utils/__init__.py +6 -0
  12. mbuzz-0.7.3/src/mbuzz/utils/fingerprint.py +12 -0
  13. {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_client.py +98 -61
  14. {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_context.py +50 -15
  15. mbuzz-0.7.3/tests/test_fingerprint.py +33 -0
  16. {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_middleware.py +242 -88
  17. mbuzz-0.7.3/uv.lock +1227 -0
  18. mbuzz-0.2.0/src/mbuzz/client/session.py +0 -38
  19. mbuzz-0.2.0/src/mbuzz/middleware/flask.py +0 -141
  20. mbuzz-0.2.0/src/mbuzz/utils/__init__.py +0 -5
  21. mbuzz-0.2.0/src/mbuzz/utils/session_id.py +0 -39
  22. mbuzz-0.2.0/tests/test_session_id.py +0 -146
  23. {mbuzz-0.2.0 → mbuzz-0.7.3}/.gitignore +0 -0
  24. {mbuzz-0.2.0 → mbuzz-0.7.3}/README.md +0 -0
  25. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/client/__init__.py +0 -0
  26. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/client/identify.py +0 -0
  27. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/config.py +0 -0
  28. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/middleware/__init__.py +0 -0
  29. {mbuzz-0.2.0 → mbuzz-0.7.3}/src/mbuzz/utils/identifier.py +0 -0
  30. {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/__init__.py +0 -0
  31. {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_api.py +0 -0
  32. {mbuzz-0.2.0 → mbuzz-0.7.3}/tests/test_config.py +0 -0
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mbuzz
3
- Version: 0.2.0
3
+ Version: 0.7.3
4
4
  Summary: Multi-touch attribution SDK for Python
5
5
  Project-URL: Homepage, https://mbuzz.co
6
6
  Project-URL: Documentation, https://mbuzz.co/docs/getting-started
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mbuzz"
7
- version = "0.2.0"
7
+ version = "0.7.3"
8
8
  description = "Multi-touch attribution SDK for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.2.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",
@@ -10,7 +10,7 @@ from .config import config
10
10
 
11
11
  logger = logging.getLogger("mbuzz")
12
12
 
13
- VERSION = "0.1.0"
13
+ VERSION = "0.7.3"
14
14
 
15
15
 
16
16
  def post(path: str, payload: Dict[str, Any]) -> bool:
@@ -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/session/user IDs and ip/user_agent from context if not provided."""
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
- # Add ip and user_agent only if provided (for server-side session resolution)
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
- session_id: str
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,6 @@
1
+ """Utility functions for mbuzz SDK."""
2
+
3
+ from .identifier import generate_id
4
+ from .fingerprint import device_fingerprint
5
+
6
+ __all__ = ["generate_id", "device_fingerprint"]
@@ -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]