mbuzz 0.1.0__tar.gz → 0.2.0__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 (26) hide show
  1. {mbuzz-0.1.0 → mbuzz-0.2.0}/PKG-INFO +1 -1
  2. {mbuzz-0.1.0 → mbuzz-0.2.0}/pyproject.toml +1 -1
  3. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/__init__.py +1 -1
  4. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/client/track.py +25 -12
  5. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/context.py +2 -0
  6. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/middleware/flask.py +24 -2
  7. mbuzz-0.2.0/src/mbuzz/utils/session_id.py +39 -0
  8. {mbuzz-0.1.0 → mbuzz-0.2.0}/tests/test_client.py +94 -0
  9. mbuzz-0.2.0/tests/test_session_id.py +146 -0
  10. {mbuzz-0.1.0 → mbuzz-0.2.0}/.gitignore +0 -0
  11. {mbuzz-0.1.0 → mbuzz-0.2.0}/README.md +0 -0
  12. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/api.py +0 -0
  13. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/client/__init__.py +0 -0
  14. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/client/conversion.py +0 -0
  15. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/client/identify.py +0 -0
  16. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/client/session.py +0 -0
  17. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/config.py +0 -0
  18. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/cookies.py +0 -0
  19. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/middleware/__init__.py +0 -0
  20. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/utils/__init__.py +0 -0
  21. {mbuzz-0.1.0 → mbuzz-0.2.0}/src/mbuzz/utils/identifier.py +0 -0
  22. {mbuzz-0.1.0 → mbuzz-0.2.0}/tests/__init__.py +0 -0
  23. {mbuzz-0.1.0 → mbuzz-0.2.0}/tests/test_api.py +0 -0
  24. {mbuzz-0.1.0 → mbuzz-0.2.0}/tests/test_config.py +0 -0
  25. {mbuzz-0.1.0 → mbuzz-0.2.0}/tests/test_context.py +0 -0
  26. {mbuzz-0.1.0 → mbuzz-0.2.0}/tests/test_middleware.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mbuzz
3
- Version: 0.1.0
3
+ Version: 0.2.0
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.1.0"
7
+ version = "0.2.0"
8
8
  description = "Multi-touch attribution SDK for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,7 +8,7 @@ from .client.track import track, TrackResult
8
8
  from .client.identify import identify
9
9
  from .client.conversion import conversion, ConversionResult
10
10
 
11
- __version__ = "0.1.0"
11
+ __version__ = "0.2.0"
12
12
 
13
13
 
14
14
  def init(
@@ -28,10 +28,12 @@ class TrackOptions:
28
28
  session_id: Optional[str] = None
29
29
  user_id: Optional[str] = None
30
30
  properties: Optional[Dict[str, Any]] = None
31
+ ip: Optional[str] = None
32
+ user_agent: Optional[str] = None
31
33
 
32
34
 
33
35
  def _resolve_ids(options: TrackOptions) -> TrackOptions:
34
- """Resolve visitor/session/user IDs from context if not provided."""
36
+ """Resolve visitor/session/user IDs and ip/user_agent from context if not provided."""
35
37
  ctx = get_context()
36
38
  if not ctx:
37
39
  return options
@@ -42,6 +44,8 @@ def _resolve_ids(options: TrackOptions) -> TrackOptions:
42
44
  session_id=options.session_id or ctx.session_id,
43
45
  user_id=options.user_id or ctx.user_id,
44
46
  properties=options.properties,
47
+ ip=options.ip or ctx.ip,
48
+ user_agent=options.user_agent or ctx.user_agent,
45
49
  )
46
50
 
47
51
 
@@ -62,18 +66,21 @@ def _validate(options: TrackOptions) -> bool:
62
66
 
63
67
  def _build_payload(options: TrackOptions, properties: Dict[str, Any]) -> Dict[str, Any]:
64
68
  """Build API payload from options."""
65
- return {
66
- "events": [
67
- {
68
- "event_type": options.event_type,
69
- "visitor_id": options.visitor_id,
70
- "session_id": options.session_id,
71
- "user_id": options.user_id,
72
- "properties": properties,
73
- "timestamp": datetime.now(timezone.utc).isoformat(),
74
- }
75
- ]
69
+ event = {
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
+ "properties": properties,
75
+ "timestamp": datetime.now(timezone.utc).isoformat(),
76
76
  }
77
+ # Add ip and user_agent only if provided (for server-side session resolution)
78
+ if options.ip:
79
+ event["ip"] = options.ip
80
+ if options.user_agent:
81
+ event["user_agent"] = options.user_agent
82
+
83
+ return {"events": [event]}
77
84
 
78
85
 
79
86
  def _parse_response(response: Optional[Dict[str, Any]], options: TrackOptions) -> TrackResult:
@@ -97,6 +104,8 @@ def track(
97
104
  session_id: Optional[str] = None,
98
105
  user_id: Optional[str] = None,
99
106
  properties: Optional[Dict[str, Any]] = None,
107
+ ip: Optional[str] = None,
108
+ user_agent: Optional[str] = None,
100
109
  ) -> TrackResult:
101
110
  """Track an event.
102
111
 
@@ -106,6 +115,8 @@ def track(
106
115
  session_id: Session ID (uses context if not provided)
107
116
  user_id: User ID (uses context if not provided)
108
117
  properties: Additional event properties
118
+ ip: Client IP address for server-side session resolution
119
+ user_agent: Client user agent for server-side session resolution
109
120
 
110
121
  Returns:
111
122
  TrackResult with success status and event details
@@ -116,6 +127,8 @@ def track(
116
127
  session_id=session_id,
117
128
  user_id=user_id,
118
129
  properties=properties,
130
+ ip=ip,
131
+ user_agent=user_agent,
119
132
  )
120
133
 
121
134
  options = _resolve_ids(options)
@@ -14,6 +14,8 @@ class RequestContext:
14
14
  user_id: Optional[str] = None
15
15
  url: Optional[str] = None
16
16
  referrer: Optional[str] = None
17
+ ip: Optional[str] = None
18
+ user_agent: Optional[str] = None
17
19
 
18
20
  def enrich_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]:
19
21
  """Add url and referrer to properties if not already present."""
@@ -8,6 +8,7 @@ from ..config import config
8
8
  from ..context import RequestContext, set_context, clear_context
9
9
  from ..cookies import VISITOR_COOKIE, SESSION_COOKIE, VISITOR_MAX_AGE, SESSION_MAX_AGE
10
10
  from ..utils.identifier import generate_id
11
+ from ..utils.session_id import generate_deterministic, generate_from_fingerprint
11
12
  from ..client.session import create_session
12
13
 
13
14
 
@@ -55,8 +56,29 @@ def _get_or_create_visitor_id() -> str:
55
56
 
56
57
 
57
58
  def _get_or_create_session_id() -> str:
58
- """Get session ID from cookie or generate new one."""
59
- return request.cookies.get(SESSION_COOKIE) or generate_id()
59
+ """Get session ID from cookie or generate deterministic one."""
60
+ existing = request.cookies.get(SESSION_COOKIE)
61
+ if existing:
62
+ return existing
63
+
64
+ existing_visitor_id = request.cookies.get(VISITOR_COOKIE)
65
+ if existing_visitor_id:
66
+ return generate_deterministic(existing_visitor_id)
67
+ else:
68
+ return generate_from_fingerprint(_get_client_ip(), _get_user_agent())
69
+
70
+
71
+ def _get_client_ip() -> str:
72
+ """Get client IP from request headers."""
73
+ forwarded = request.headers.get("X-Forwarded-For", "")
74
+ if forwarded:
75
+ return forwarded.split(",")[0].strip()
76
+ return request.remote_addr or "unknown"
77
+
78
+
79
+ def _get_user_agent() -> str:
80
+ """Get user agent from request."""
81
+ return request.headers.get("User-Agent", "unknown")
60
82
 
61
83
 
62
84
  def _set_request_context(visitor_id: str, session_id: str) -> None:
@@ -0,0 +1,39 @@
1
+ """Deterministic session ID generation."""
2
+
3
+ import hashlib
4
+ import secrets
5
+ import time
6
+
7
+ SESSION_TIMEOUT_SECONDS = 1800
8
+ SESSION_ID_LENGTH = 64
9
+ FINGERPRINT_LENGTH = 32
10
+
11
+
12
+ def generate_deterministic(visitor_id: str, timestamp: int | None = None) -> str:
13
+ """Generate session ID for returning visitors."""
14
+ if timestamp is None:
15
+ timestamp = int(time.time())
16
+ time_bucket = timestamp // SESSION_TIMEOUT_SECONDS
17
+ raw = f"{visitor_id}_{time_bucket}"
18
+ return hashlib.sha256(raw.encode()).hexdigest()[:SESSION_ID_LENGTH]
19
+
20
+
21
+ def generate_from_fingerprint(
22
+ client_ip: str,
23
+ user_agent: str,
24
+ timestamp: int | None = None
25
+ ) -> str:
26
+ """Generate session ID for new visitors using IP+UA fingerprint."""
27
+ if timestamp is None:
28
+ timestamp = int(time.time())
29
+ fingerprint = hashlib.sha256(
30
+ f"{client_ip}|{user_agent}".encode()
31
+ ).hexdigest()[:FINGERPRINT_LENGTH]
32
+ time_bucket = timestamp // SESSION_TIMEOUT_SECONDS
33
+ raw = f"{fingerprint}_{time_bucket}"
34
+ return hashlib.sha256(raw.encode()).hexdigest()[:SESSION_ID_LENGTH]
35
+
36
+
37
+ def generate_random() -> str:
38
+ """Generate random session ID (fallback)."""
39
+ return secrets.token_hex(32)
@@ -112,6 +112,100 @@ class TestTrack:
112
112
  result = track(event_type="page_view", visitor_id="vid_123")
113
113
  assert result.success is False
114
114
 
115
+ # Server-side session resolution tests (v0.2.0+)
116
+
117
+ @patch("mbuzz.client.track.post_with_response")
118
+ def test_accepts_ip_parameter(self, mock_post):
119
+ """Should accept and forward ip parameter."""
120
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
121
+
122
+ result = track(
123
+ event_type="page_view",
124
+ visitor_id="vid_123",
125
+ ip="192.168.1.100"
126
+ )
127
+ assert result.success is True
128
+
129
+ call_args = mock_post.call_args[0]
130
+ payload = call_args[1]
131
+ assert payload["events"][0]["ip"] == "192.168.1.100"
132
+
133
+ @patch("mbuzz.client.track.post_with_response")
134
+ def test_accepts_user_agent_parameter(self, mock_post):
135
+ """Should accept and forward user_agent parameter."""
136
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
137
+
138
+ result = track(
139
+ event_type="page_view",
140
+ visitor_id="vid_123",
141
+ user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
142
+ )
143
+ assert result.success is True
144
+
145
+ call_args = mock_post.call_args[0]
146
+ payload = call_args[1]
147
+ assert payload["events"][0]["user_agent"] == "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
148
+
149
+ @patch("mbuzz.client.track.post_with_response")
150
+ def test_accepts_both_ip_and_user_agent(self, mock_post):
151
+ """Should accept both ip and user_agent parameters."""
152
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
153
+
154
+ result = track(
155
+ event_type="page_view",
156
+ visitor_id="vid_123",
157
+ ip="10.0.0.1",
158
+ user_agent="Chrome/120"
159
+ )
160
+ assert result.success is True
161
+
162
+ call_args = mock_post.call_args[0]
163
+ payload = call_args[1]
164
+ assert payload["events"][0]["ip"] == "10.0.0.1"
165
+ assert payload["events"][0]["user_agent"] == "Chrome/120"
166
+
167
+ @patch("mbuzz.client.track.post_with_response")
168
+ def test_uses_context_ip_and_user_agent(self, mock_post):
169
+ """Should use ip and user_agent from context if not explicitly provided."""
170
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
171
+ set_context(RequestContext(
172
+ visitor_id="ctx_vid",
173
+ session_id="ctx_sid",
174
+ ip="203.0.113.50",
175
+ user_agent="Safari/17.0"
176
+ ))
177
+
178
+ result = track(event_type="page_view")
179
+ assert result.success is True
180
+
181
+ call_args = mock_post.call_args[0]
182
+ payload = call_args[1]
183
+ assert payload["events"][0]["ip"] == "203.0.113.50"
184
+ assert payload["events"][0]["user_agent"] == "Safari/17.0"
185
+
186
+ @patch("mbuzz.client.track.post_with_response")
187
+ def test_explicit_ip_overrides_context(self, mock_post):
188
+ """Should use explicit ip/user_agent over context."""
189
+ mock_post.return_value = {"events": [{"id": "evt_123"}]}
190
+ set_context(RequestContext(
191
+ visitor_id="ctx_vid",
192
+ session_id="ctx_sid",
193
+ ip="context_ip",
194
+ user_agent="context_ua"
195
+ ))
196
+
197
+ result = track(
198
+ event_type="page_view",
199
+ ip="explicit_ip",
200
+ user_agent="explicit_ua"
201
+ )
202
+ assert result.success is True
203
+
204
+ call_args = mock_post.call_args[0]
205
+ payload = call_args[1]
206
+ assert payload["events"][0]["ip"] == "explicit_ip"
207
+ assert payload["events"][0]["user_agent"] == "explicit_ua"
208
+
115
209
 
116
210
  class TestIdentify:
117
211
  """Test identify function."""
@@ -0,0 +1,146 @@
1
+ """Tests for deterministic session ID generation."""
2
+
3
+ import pytest
4
+ from mbuzz.utils.session_id import (
5
+ generate_deterministic,
6
+ generate_from_fingerprint,
7
+ generate_random,
8
+ )
9
+
10
+
11
+ class TestGenerateDeterministic:
12
+ sample_visitor_id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
13
+ sample_timestamp = 1735500000
14
+
15
+ def test_returns_64_char_hex_string(self):
16
+ result = generate_deterministic(self.sample_visitor_id, self.sample_timestamp)
17
+ assert len(result) == 64
18
+ assert all(c in "0123456789abcdef" for c in result)
19
+
20
+ def test_is_consistent(self):
21
+ result1 = generate_deterministic(self.sample_visitor_id, self.sample_timestamp)
22
+ result2 = generate_deterministic(self.sample_visitor_id, self.sample_timestamp)
23
+ assert result1 == result2
24
+
25
+ def test_same_within_time_bucket(self):
26
+ # bucket = timestamp / 1800
27
+ # 1735500000 / 1800 = 964166
28
+ # 1735500599 / 1800 = 964166 (last second of bucket)
29
+ timestamp1 = 1735500000
30
+ timestamp2 = 1735500001
31
+ timestamp3 = 1735500599
32
+
33
+ result1 = generate_deterministic(self.sample_visitor_id, timestamp1)
34
+ result2 = generate_deterministic(self.sample_visitor_id, timestamp2)
35
+ result3 = generate_deterministic(self.sample_visitor_id, timestamp3)
36
+
37
+ assert result1 == result2
38
+ assert result1 == result3
39
+
40
+ def test_different_across_time_buckets(self):
41
+ timestamp1 = 1735500000
42
+ timestamp2 = 1735501800 # Next bucket
43
+
44
+ result1 = generate_deterministic(self.sample_visitor_id, timestamp1)
45
+ result2 = generate_deterministic(self.sample_visitor_id, timestamp2)
46
+
47
+ assert result1 != result2
48
+
49
+ def test_different_for_different_visitors(self):
50
+ result1 = generate_deterministic("visitor_a", self.sample_timestamp)
51
+ result2 = generate_deterministic("visitor_b", self.sample_timestamp)
52
+
53
+ assert result1 != result2
54
+
55
+
56
+ class TestGenerateFromFingerprint:
57
+ sample_ip = "203.0.113.42"
58
+ sample_user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
59
+ sample_timestamp = 1735500000
60
+
61
+ def test_returns_64_char_hex_string(self):
62
+ result = generate_from_fingerprint(
63
+ self.sample_ip, self.sample_user_agent, self.sample_timestamp
64
+ )
65
+ assert len(result) == 64
66
+ assert all(c in "0123456789abcdef" for c in result)
67
+
68
+ def test_is_consistent(self):
69
+ result1 = generate_from_fingerprint(
70
+ self.sample_ip, self.sample_user_agent, self.sample_timestamp
71
+ )
72
+ result2 = generate_from_fingerprint(
73
+ self.sample_ip, self.sample_user_agent, self.sample_timestamp
74
+ )
75
+ assert result1 == result2
76
+
77
+ def test_same_within_time_bucket(self):
78
+ timestamp1 = 1735500000
79
+ timestamp2 = 1735500001
80
+
81
+ result1 = generate_from_fingerprint(
82
+ self.sample_ip, self.sample_user_agent, timestamp1
83
+ )
84
+ result2 = generate_from_fingerprint(
85
+ self.sample_ip, self.sample_user_agent, timestamp2
86
+ )
87
+
88
+ assert result1 == result2
89
+
90
+ def test_different_across_time_buckets(self):
91
+ timestamp1 = 1735500000
92
+ timestamp2 = 1735501800
93
+
94
+ result1 = generate_from_fingerprint(
95
+ self.sample_ip, self.sample_user_agent, timestamp1
96
+ )
97
+ result2 = generate_from_fingerprint(
98
+ self.sample_ip, self.sample_user_agent, timestamp2
99
+ )
100
+
101
+ assert result1 != result2
102
+
103
+ def test_different_for_different_ips(self):
104
+ result1 = generate_from_fingerprint(
105
+ "192.168.1.1", self.sample_user_agent, self.sample_timestamp
106
+ )
107
+ result2 = generate_from_fingerprint(
108
+ "192.168.1.2", self.sample_user_agent, self.sample_timestamp
109
+ )
110
+
111
+ assert result1 != result2
112
+
113
+ def test_different_for_different_user_agents(self):
114
+ result1 = generate_from_fingerprint(
115
+ self.sample_ip, "Mozilla/5.0 Chrome", self.sample_timestamp
116
+ )
117
+ result2 = generate_from_fingerprint(
118
+ self.sample_ip, "Mozilla/5.0 Safari", self.sample_timestamp
119
+ )
120
+
121
+ assert result1 != result2
122
+
123
+
124
+ class TestGenerateRandom:
125
+ def test_returns_64_char_hex_string(self):
126
+ result = generate_random()
127
+ assert len(result) == 64
128
+ assert all(c in "0123456789abcdef" for c in result)
129
+
130
+ def test_returns_unique_ids(self):
131
+ result1 = generate_random()
132
+ result2 = generate_random()
133
+ assert result1 != result2
134
+
135
+
136
+ class TestCrossMethod:
137
+ def test_deterministic_and_fingerprint_produce_different_ids(self):
138
+ visitor_id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
139
+ ip = "203.0.113.42"
140
+ user_agent = "Mozilla/5.0"
141
+ timestamp = 1735500000
142
+
143
+ deterministic = generate_deterministic(visitor_id, timestamp)
144
+ fingerprint = generate_from_fingerprint(ip, user_agent, timestamp)
145
+
146
+ assert deterministic != fingerprint
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes