mbuzz 0.7.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 (28) hide show
  1. mbuzz-0.7.3/CHANGELOG.md +18 -0
  2. {mbuzz-0.7.0 → mbuzz-0.7.3}/PKG-INFO +1 -1
  3. {mbuzz-0.7.0 → mbuzz-0.7.3}/pyproject.toml +1 -1
  4. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/__init__.py +1 -1
  5. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/api.py +1 -1
  6. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/middleware/flask.py +65 -0
  7. mbuzz-0.7.3/src/mbuzz/utils/__init__.py +6 -0
  8. mbuzz-0.7.3/src/mbuzz/utils/fingerprint.py +12 -0
  9. {mbuzz-0.7.0 → mbuzz-0.7.3}/tests/test_client.py +40 -0
  10. mbuzz-0.7.3/tests/test_fingerprint.py +33 -0
  11. {mbuzz-0.7.0 → mbuzz-0.7.3}/tests/test_middleware.py +205 -1
  12. mbuzz-0.7.3/uv.lock +1227 -0
  13. mbuzz-0.7.0/src/mbuzz/utils/__init__.py +0 -5
  14. {mbuzz-0.7.0 → mbuzz-0.7.3}/.gitignore +0 -0
  15. {mbuzz-0.7.0 → mbuzz-0.7.3}/README.md +0 -0
  16. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/client/__init__.py +0 -0
  17. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/client/conversion.py +0 -0
  18. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/client/identify.py +0 -0
  19. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/client/track.py +0 -0
  20. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/config.py +0 -0
  21. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/context.py +0 -0
  22. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/cookies.py +0 -0
  23. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/middleware/__init__.py +0 -0
  24. {mbuzz-0.7.0 → mbuzz-0.7.3}/src/mbuzz/utils/identifier.py +0 -0
  25. {mbuzz-0.7.0 → mbuzz-0.7.3}/tests/__init__.py +0 -0
  26. {mbuzz-0.7.0 → mbuzz-0.7.3}/tests/test_api.py +0 -0
  27. {mbuzz-0.7.0 → mbuzz-0.7.3}/tests/test_config.py +0 -0
  28. {mbuzz-0.7.0 → mbuzz-0.7.3}/tests/test_context.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.7.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.7.0"
7
+ version = "0.7.3"
8
8
  description = "Multi-touch attribution SDK for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,7 +9,7 @@ from .client.track import track, TrackResult
9
9
  from .client.identify import identify
10
10
  from .client.conversion import conversion, ConversionResult
11
11
 
12
- __version__ = "0.7.0"
12
+ __version__ = "0.7.3"
13
13
 
14
14
 
15
15
  def init(
@@ -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,14 +1,46 @@
1
1
  """Flask middleware for mbuzz tracking."""
2
2
  # NOTE: Session cookie removed in 0.7.0 - server handles session resolution
3
3
 
4
+ import threading
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from typing import Optional
8
+
4
9
  from flask import Flask, request, g, Response
5
10
 
11
+ from ..api import post
6
12
  from ..config import config
7
13
  from ..context import RequestContext, set_context, clear_context
8
14
  from ..cookies import VISITOR_COOKIE, VISITOR_MAX_AGE
15
+ from ..utils.fingerprint import device_fingerprint
9
16
  from ..utils.identifier import generate_id
10
17
 
11
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
+
12
44
  def init_app(app: Flask) -> None:
13
45
  """Initialize mbuzz tracking for Flask app."""
14
46
 
@@ -24,6 +56,11 @@ def init_app(app: Flask) -> None:
24
56
  _set_request_context(visitor_id, ip, user_agent)
25
57
  _store_in_g(visitor_id)
26
58
 
59
+ if should_create_session():
60
+ _create_session_async(
61
+ visitor_id, request.url, request.referrer, ip, user_agent
62
+ )
63
+
27
64
  @app.after_request
28
65
  def after_request(response: Response) -> Response:
29
66
  if not hasattr(g, "mbuzz_visitor_id"):
@@ -83,6 +120,34 @@ def _store_in_g(visitor_id: str) -> None:
83
120
  g.mbuzz_is_new_visitor = VISITOR_COOKIE not in request.cookies
84
121
 
85
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
+
86
151
  def _set_cookies(response: Response) -> None:
87
152
  """Set visitor cookie on response."""
88
153
  secure = request.is_secure
@@ -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]
@@ -411,3 +411,43 @@ class TestConversion:
411
411
  call_args = mock_post.call_args[0]
412
412
  payload = call_args[1]
413
413
  assert payload["identifier"] == {"email": "test@example.com"}
414
+
415
+ @patch("mbuzz.client.conversion.post_with_response")
416
+ def test_uses_context_ip_and_user_agent(self, mock_post):
417
+ """Should use ip and user_agent from context if not explicitly provided."""
418
+ mock_post.return_value = {"conversion": {"id": "conv_123"}}
419
+ set_context(RequestContext(
420
+ visitor_id="ctx_vid",
421
+ ip="203.0.113.50",
422
+ user_agent="Safari/17.0",
423
+ ))
424
+
425
+ result = conversion(conversion_type="purchase")
426
+ assert result.success is True
427
+
428
+ call_args = mock_post.call_args[0]
429
+ payload = call_args[1]
430
+ assert payload["ip"] == "203.0.113.50"
431
+ assert payload["user_agent"] == "Safari/17.0"
432
+
433
+ @patch("mbuzz.client.conversion.post_with_response")
434
+ def test_explicit_ip_overrides_context(self, mock_post):
435
+ """Should use explicit ip/user_agent over context."""
436
+ mock_post.return_value = {"conversion": {"id": "conv_123"}}
437
+ set_context(RequestContext(
438
+ visitor_id="ctx_vid",
439
+ ip="context_ip",
440
+ user_agent="context_ua",
441
+ ))
442
+
443
+ result = conversion(
444
+ conversion_type="purchase",
445
+ ip="explicit_ip",
446
+ user_agent="explicit_ua"
447
+ )
448
+ assert result.success is True
449
+
450
+ call_args = mock_post.call_args[0]
451
+ payload = call_args[1]
452
+ assert payload["ip"] == "explicit_ip"
453
+ assert payload["user_agent"] == "explicit_ua"
@@ -0,0 +1,33 @@
1
+ """Tests for device fingerprint utility."""
2
+
3
+ from mbuzz.utils.fingerprint import device_fingerprint
4
+
5
+
6
+ class TestDeviceFingerprint:
7
+ """Test device_fingerprint matches server-side SHA256(ip|user_agent)[0:32]."""
8
+
9
+ def test_ruby_parity(self):
10
+ """Must produce identical output to Ruby: Digest::SHA256.hexdigest('127.0.0.1|Mozilla/5.0')[0,32]."""
11
+ result = device_fingerprint("127.0.0.1", "Mozilla/5.0")
12
+ assert result == "ea687534a507e203bdef87cee3cc60c5"
13
+
14
+ def test_deterministic(self):
15
+ """Same input must always produce same output."""
16
+ a = device_fingerprint("10.0.0.1", "TestAgent/1.0")
17
+ b = device_fingerprint("10.0.0.1", "TestAgent/1.0")
18
+ assert a == b
19
+
20
+ def test_unique_for_different_inputs(self):
21
+ """Different inputs must produce different outputs."""
22
+ a = device_fingerprint("10.0.0.1", "Agent-A")
23
+ b = device_fingerprint("10.0.0.2", "Agent-A")
24
+ c = device_fingerprint("10.0.0.1", "Agent-B")
25
+ assert a != b
26
+ assert a != c
27
+ assert b != c
28
+
29
+ def test_returns_32_char_hex(self):
30
+ """Must return exactly 32 lowercase hex characters."""
31
+ result = device_fingerprint("192.168.1.1", "Chrome/120")
32
+ assert len(result) == 32
33
+ assert all(c in "0123456789abcdef" for c in result)
@@ -4,7 +4,7 @@ import pytest
4
4
  from unittest.mock import patch, MagicMock
5
5
  from flask import Flask, g
6
6
 
7
- from mbuzz.middleware.flask import init_app
7
+ from mbuzz.middleware.flask import init_app, should_create_session
8
8
  from mbuzz.config import config
9
9
  from mbuzz.context import get_context, clear_context
10
10
  from mbuzz.cookies import VISITOR_COOKIE, VISITOR_MAX_AGE
@@ -342,3 +342,207 @@ class TestFlaskMiddleware:
342
342
  assert response.status_code == 500
343
343
  # Context should still be cleared
344
344
  assert get_context() is None
345
+
346
+
347
+ class TestNavigationDetection:
348
+ """Test should_create_session() — Sec-Fetch-* whitelist + framework blacklist fallback.
349
+
350
+ Mirrors the e2e test at sdk_integration_tests/scenarios/navigation_detection_test.rb.
351
+ """
352
+
353
+ def setup_method(self):
354
+ config.reset()
355
+ clear_context()
356
+ self.app = Flask(__name__)
357
+ self.app.config["TESTING"] = True
358
+
359
+ @self.app.route("/")
360
+ def index():
361
+ return "OK"
362
+
363
+ def teardown_method(self):
364
+ config.reset()
365
+ clear_context()
366
+
367
+ # -----------------------------------------------------------------
368
+ # Whitelist path: modern browsers with Sec-Fetch-* headers
369
+ # -----------------------------------------------------------------
370
+
371
+ def test_real_navigation_returns_true(self):
372
+ """navigate + document = real page navigation → create session."""
373
+ with self.app.test_request_context(
374
+ "/", headers={
375
+ "Sec-Fetch-Mode": "navigate",
376
+ "Sec-Fetch-Dest": "document",
377
+ }
378
+ ):
379
+ assert should_create_session() is True
380
+
381
+ def test_turbo_frame_returns_false(self):
382
+ """same-origin + empty + Turbo-Frame → sub-request → skip."""
383
+ with self.app.test_request_context(
384
+ "/", headers={
385
+ "Sec-Fetch-Mode": "same-origin",
386
+ "Sec-Fetch-Dest": "empty",
387
+ "Turbo-Frame": "content_frame",
388
+ }
389
+ ):
390
+ assert should_create_session() is False
391
+
392
+ def test_htmx_returns_false(self):
393
+ """same-origin + empty + HX-Request → sub-request → skip."""
394
+ with self.app.test_request_context(
395
+ "/", headers={
396
+ "Sec-Fetch-Mode": "same-origin",
397
+ "Sec-Fetch-Dest": "empty",
398
+ "HX-Request": "true",
399
+ }
400
+ ):
401
+ assert should_create_session() is False
402
+
403
+ def test_fetch_xhr_returns_false(self):
404
+ """cors + empty → fetch/XHR → skip."""
405
+ with self.app.test_request_context(
406
+ "/", headers={
407
+ "Sec-Fetch-Mode": "cors",
408
+ "Sec-Fetch-Dest": "empty",
409
+ }
410
+ ):
411
+ assert should_create_session() is False
412
+
413
+ def test_prefetch_returns_false(self):
414
+ """navigate + document + Sec-Purpose: prefetch → skip."""
415
+ with self.app.test_request_context(
416
+ "/", headers={
417
+ "Sec-Fetch-Mode": "navigate",
418
+ "Sec-Fetch-Dest": "document",
419
+ "Sec-Purpose": "prefetch",
420
+ }
421
+ ):
422
+ assert should_create_session() is False
423
+
424
+ def test_iframe_returns_false(self):
425
+ """navigate + iframe → not a document navigation → skip."""
426
+ with self.app.test_request_context(
427
+ "/", headers={
428
+ "Sec-Fetch-Mode": "navigate",
429
+ "Sec-Fetch-Dest": "iframe",
430
+ }
431
+ ):
432
+ assert should_create_session() is False
433
+
434
+ # -----------------------------------------------------------------
435
+ # Blacklist fallback: old browsers without Sec-Fetch-* headers
436
+ # -----------------------------------------------------------------
437
+
438
+ def test_old_browser_no_framework_headers_returns_true(self):
439
+ """No Sec-Fetch, no framework headers → allow (legacy browser)."""
440
+ with self.app.test_request_context("/"):
441
+ assert should_create_session() is True
442
+
443
+ def test_old_browser_turbo_frame_returns_false(self):
444
+ """No Sec-Fetch + Turbo-Frame → blacklist catches it."""
445
+ with self.app.test_request_context(
446
+ "/", headers={"Turbo-Frame": "lazy_banner"}
447
+ ):
448
+ assert should_create_session() is False
449
+
450
+ def test_old_browser_hx_request_returns_false(self):
451
+ """No Sec-Fetch + HX-Request → blacklist catches it."""
452
+ with self.app.test_request_context(
453
+ "/", headers={"HX-Request": "true"}
454
+ ):
455
+ assert should_create_session() is False
456
+
457
+ def test_old_browser_xhr_returns_false(self):
458
+ """No Sec-Fetch + X-Requested-With: XMLHttpRequest → blacklist catches it."""
459
+ with self.app.test_request_context(
460
+ "/", headers={"X-Requested-With": "XMLHttpRequest"}
461
+ ):
462
+ assert should_create_session() is False
463
+
464
+ def test_old_browser_unpoly_returns_false(self):
465
+ """No Sec-Fetch + X-Up-Version → blacklist catches it."""
466
+ with self.app.test_request_context(
467
+ "/", headers={"X-Up-Version": "3.0.0"}
468
+ ):
469
+ assert should_create_session() is False
470
+
471
+ # -----------------------------------------------------------------
472
+ # Middleware integration: session creation + cookie behavior
473
+ # -----------------------------------------------------------------
474
+
475
+ @patch("mbuzz.middleware.flask.post")
476
+ def test_navigation_calls_post_sessions(self, mock_post):
477
+ """Real navigation must fire POST /sessions."""
478
+ config.init(api_key="sk_test_123")
479
+ init_app(self.app)
480
+
481
+ with self.app.test_client() as client:
482
+ client.get("/", headers={
483
+ "Sec-Fetch-Mode": "navigate",
484
+ "Sec-Fetch-Dest": "document",
485
+ })
486
+
487
+ mock_post.assert_called_once()
488
+ args = mock_post.call_args
489
+ assert args[0][0] == "/sessions"
490
+ payload = args[0][1]
491
+ assert "session" in payload
492
+ assert "visitor_id" in payload["session"]
493
+ assert "session_id" in payload["session"]
494
+ assert "device_fingerprint" in payload["session"]
495
+ assert len(payload["session"]["device_fingerprint"]) == 32
496
+
497
+ @patch("mbuzz.middleware.flask.post")
498
+ def test_turbo_frame_does_not_call_post(self, mock_post):
499
+ """Turbo frame request must NOT fire POST /sessions."""
500
+ config.init(api_key="sk_test_123")
501
+ init_app(self.app)
502
+
503
+ with self.app.test_client() as client:
504
+ client.get("/", headers={
505
+ "Sec-Fetch-Mode": "same-origin",
506
+ "Sec-Fetch-Dest": "empty",
507
+ "Turbo-Frame": "banner",
508
+ })
509
+
510
+ mock_post.assert_not_called()
511
+
512
+ @patch("mbuzz.middleware.flask.post")
513
+ def test_visitor_cookie_set_on_sub_request(self, mock_post):
514
+ """Visitor cookie must be set even when session creation is skipped."""
515
+ config.init(api_key="sk_test_123")
516
+ init_app(self.app)
517
+
518
+ with self.app.test_client() as client:
519
+ response = client.get("/", headers={
520
+ "Sec-Fetch-Mode": "same-origin",
521
+ "Sec-Fetch-Dest": "empty",
522
+ "Turbo-Frame": "banner",
523
+ })
524
+
525
+ cookies = response.headers.getlist("Set-Cookie")
526
+ visitor_cookie = next(
527
+ (c for c in cookies if VISITOR_COOKIE in c), None
528
+ )
529
+ assert visitor_cookie is not None
530
+
531
+ @patch("mbuzz.middleware.flask.post")
532
+ def test_response_always_succeeds(self, mock_post):
533
+ """Request completes normally regardless of navigation detection outcome."""
534
+ config.init(api_key="sk_test_123")
535
+ init_app(self.app)
536
+
537
+ with self.app.test_client() as client:
538
+ nav = client.get("/", headers={
539
+ "Sec-Fetch-Mode": "navigate",
540
+ "Sec-Fetch-Dest": "document",
541
+ })
542
+ sub = client.get("/", headers={
543
+ "Sec-Fetch-Mode": "same-origin",
544
+ "Sec-Fetch-Dest": "empty",
545
+ })
546
+
547
+ assert nav.status_code == 200
548
+ assert sub.status_code == 200