metricsfirst 0.1.7__py3-none-any.whl

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.
@@ -0,0 +1,30 @@
1
+ """
2
+ MetricsFirst SDK for Python
3
+ Track analytics for your Telegram bots
4
+ """
5
+
6
+ from .client import MetricsFirst
7
+ from .async_client import AsyncMetricsFirst
8
+ from .types import (
9
+ ServiceEventData,
10
+ ErrorEventData,
11
+ ErrorSeverity,
12
+ PurchaseEventData,
13
+ PurchaseStatus,
14
+ RecurringChargeEventData,
15
+ UserIdentifyData,
16
+ )
17
+
18
+ __version__ = "0.1.1"
19
+ __all__ = [
20
+ "MetricsFirst",
21
+ "AsyncMetricsFirst",
22
+ "ServiceEventData",
23
+ "ErrorEventData",
24
+ "ErrorSeverity",
25
+ "PurchaseEventData",
26
+ "PurchaseStatus",
27
+ "RecurringChargeEventData",
28
+ "UserIdentifyData",
29
+ ]
30
+
@@ -0,0 +1,365 @@
1
+ """
2
+ Asynchronous MetricsFirst client
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from dataclasses import asdict
9
+ from typing import Optional, List, Dict, Any
10
+
11
+ try:
12
+ import aiohttp
13
+ HAS_AIOHTTP = True
14
+ except ImportError:
15
+ HAS_AIOHTTP = False
16
+
17
+ from .types import (
18
+ ServiceEventData,
19
+ ErrorEventData,
20
+ PurchaseEventData,
21
+ PurchaseStatus,
22
+ RecurringChargeEventData,
23
+ UserIdentifyData,
24
+ )
25
+
26
+ logger = logging.getLogger("metricsfirst")
27
+
28
+
29
+ class AsyncMetricsFirst:
30
+ """
31
+ Asynchronous MetricsFirst SDK client.
32
+
33
+ Note: Commands and interactions are tracked automatically.
34
+ Use this SDK to track custom events like services, purchases, and errors.
35
+
36
+ Usage:
37
+ mf = AsyncMetricsFirst(bot_id="your_bot_id", api_key="your_api_key")
38
+ await mf.start()
39
+ mf.track_service(ServiceEventData(user_id=123, service_name="generation"))
40
+ await mf.shutdown() # Call on exit to flush remaining events
41
+ """
42
+
43
+ DEFAULT_API_URL = "https://metricsfirst.io/api"
44
+
45
+ def __init__(
46
+ self,
47
+ bot_id: str,
48
+ api_key: str,
49
+ api_url: Optional[str] = None,
50
+ batch_events: bool = True,
51
+ batch_size: int = 10,
52
+ batch_interval: float = 5.0,
53
+ debug: bool = False,
54
+ timeout: float = 10.0,
55
+ ):
56
+ """
57
+ Initialize the AsyncMetricsFirst client.
58
+
59
+ Args:
60
+ bot_id: Your bot's unique identifier
61
+ api_key: Your API key from MetricsFirst dashboard
62
+ api_url: Custom API URL (optional)
63
+ batch_events: Whether to batch events before sending
64
+ batch_size: Number of events per batch
65
+ batch_interval: Seconds between batch flushes
66
+ debug: Enable debug logging
67
+ timeout: HTTP request timeout in seconds
68
+ """
69
+ if not HAS_AIOHTTP:
70
+ raise ImportError(
71
+ "aiohttp is required for AsyncMetricsFirst. "
72
+ "Install with: pip install metricsfirst[async]"
73
+ )
74
+
75
+ self.bot_id = bot_id
76
+ self.api_key = api_key
77
+ self.api_url = (api_url or self.DEFAULT_API_URL).rstrip("/")
78
+ self.batch_events = batch_events
79
+ self.batch_size = batch_size
80
+ self.batch_interval = batch_interval
81
+ self.timeout = aiohttp.ClientTimeout(total=timeout)
82
+
83
+ if debug:
84
+ logging.basicConfig(level=logging.DEBUG)
85
+ logger.setLevel(logging.DEBUG)
86
+
87
+ # Max 1000 events in queue to prevent memory issues
88
+ self._queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
89
+ self._session: Optional[aiohttp.ClientSession] = None
90
+ self._worker_task: Optional[asyncio.Task] = None
91
+ self._shutdown_event = asyncio.Event()
92
+
93
+ async def start(self):
94
+ """Start the client and background worker"""
95
+ self._session = aiohttp.ClientSession(timeout=self.timeout)
96
+ self._shutdown_event.clear()
97
+
98
+ if self.batch_events:
99
+ self._worker_task = asyncio.create_task(self._worker())
100
+
101
+ logger.debug("AsyncMetricsFirst client started")
102
+
103
+ async def _worker(self):
104
+ """Background worker that flushes events periodically"""
105
+ events: List[Dict[str, Any]] = []
106
+
107
+ while not self._shutdown_event.is_set():
108
+ try:
109
+ # Try to get an event with timeout
110
+ event = await asyncio.wait_for(
111
+ self._queue.get(),
112
+ timeout=self.batch_interval
113
+ )
114
+ events.append(event)
115
+
116
+ # Flush if batch is full
117
+ if len(events) >= self.batch_size:
118
+ await self._flush_events(events)
119
+ events = []
120
+
121
+ except asyncio.TimeoutError:
122
+ # Timeout - flush what we have
123
+ if events:
124
+ await self._flush_events(events)
125
+ events = []
126
+ except asyncio.CancelledError:
127
+ break
128
+
129
+ # Flush remaining events on shutdown
130
+ while not self._queue.empty():
131
+ try:
132
+ events.append(self._queue.get_nowait())
133
+ except asyncio.QueueEmpty:
134
+ break
135
+
136
+ if events:
137
+ await self._flush_events(events)
138
+
139
+ async def _flush_events(self, events: List[Dict[str, Any]]):
140
+ """Send a batch of events to the API"""
141
+ if not events:
142
+ return
143
+
144
+ try:
145
+ tasks = [
146
+ self._send_event(event["type"], event["data"])
147
+ for event in events
148
+ ]
149
+ await asyncio.gather(*tasks, return_exceptions=True)
150
+ except Exception as e:
151
+ logger.error(f"Failed to flush events: {e}")
152
+
153
+ async def _send_event(self, event_type: str, data: Dict[str, Any]):
154
+ """Send a single event to the API"""
155
+ if not self._session:
156
+ logger.error("Client not started. Call start() first.")
157
+ return
158
+
159
+ payload = {
160
+ "botId": self.bot_id,
161
+ "type": event_type,
162
+ "data": data,
163
+ }
164
+
165
+ # URL: base/analytics/track (base already includes /api if provided)
166
+ url = f"{self.api_url}/analytics/track"
167
+
168
+ try:
169
+ async with self._session.post(
170
+ url,
171
+ json=payload,
172
+ headers={"X-API-Key": self.api_key},
173
+ ) as response:
174
+ if response.status != 200:
175
+ text = await response.text()
176
+ try:
177
+ error_data = json.loads(text)
178
+ error_message = error_data.get("error", text[:200])
179
+ except json.JSONDecodeError:
180
+ error_message = text[:200]
181
+ logger.warning(f"API returned status {response.status} for {url}: {error_message}")
182
+ else:
183
+ logger.debug(f"Event sent: {event_type}")
184
+
185
+ except aiohttp.ClientError as e:
186
+ logger.error(f"HTTP error sending event: {e}")
187
+ except Exception as e:
188
+ logger.error(f"Error sending event: {e}")
189
+
190
+ def _track(self, event_type: str, data: Dict[str, Any]):
191
+ """Queue or send an event (non-blocking, fire-and-forget)"""
192
+ if self.batch_events:
193
+ # Non-blocking put to queue - background task will handle sending
194
+ try:
195
+ self._queue.put_nowait({"type": event_type, "data": data})
196
+ except asyncio.QueueFull:
197
+ logger.warning("Event queue full, dropping event")
198
+ else:
199
+ # Fire and forget - create task without awaiting
200
+ asyncio.create_task(self._send_event(event_type, data))
201
+
202
+ def _clean_dict(self, d: Dict[str, Any]) -> Dict[str, Any]:
203
+ """Remove None values and convert enums"""
204
+ result = {}
205
+ for k, v in d.items():
206
+ if v is None:
207
+ continue
208
+ if hasattr(v, "value"): # Enum
209
+ result[k] = v.value
210
+ elif isinstance(v, dict):
211
+ result[k] = self._clean_dict(v)
212
+ else:
213
+ result[k] = v
214
+ return result
215
+
216
+ # ==========================================
217
+ # Tracking Methods (fire-and-forget, non-blocking)
218
+ # Note: Commands/interactions are tracked automatically
219
+ # ==========================================
220
+
221
+ def track(
222
+ self,
223
+ user_id: int,
224
+ event_name: str,
225
+ properties: Optional[Dict[str, Any]] = None,
226
+ ):
227
+ """
228
+ Track a custom event with dynamic properties.
229
+
230
+ Args:
231
+ user_id: User ID (Telegram user ID)
232
+ event_name: Name of the event (e.g., 'STORY_RESPONSE', 'BUTTON_CLICK')
233
+ properties: Dictionary of event properties (any key-value pairs)
234
+
235
+ Example:
236
+ mf.track(123456, 'STORY_RESPONSE', {
237
+ 'target': 'username123',
238
+ 'url': 'https://example.com/story',
239
+ 'response_time_ms': 150,
240
+ 'success': True
241
+ })
242
+ """
243
+ data = {
244
+ "eventName": event_name,
245
+ "properties": properties or {},
246
+ "userId": user_id,
247
+ "lib": "python",
248
+ "libVersion": "1.0.0",
249
+ }
250
+
251
+ self._track("custom", data)
252
+
253
+ def track_service(self, data: ServiceEventData):
254
+ """Track a service provided (fire-and-forget)"""
255
+ self._track("service_provided", self._clean_dict(asdict(data)))
256
+
257
+ def track_error(self, data: ErrorEventData):
258
+ """Track an error (fire-and-forget)"""
259
+ self._track("error", self._clean_dict(asdict(data)))
260
+
261
+ def track_error_from_exception(
262
+ self,
263
+ exception: Exception,
264
+ user_id: Optional[int] = None,
265
+ severity: str = "error",
266
+ context: Optional[Dict[str, Any]] = None,
267
+ ):
268
+ """Track an error from an exception (fire-and-forget)"""
269
+ import traceback
270
+
271
+ err_data = ErrorEventData(
272
+ error_type=type(exception).__name__,
273
+ error_message=str(exception),
274
+ user_id=user_id,
275
+ error_stack=traceback.format_exc(),
276
+ severity=severity,
277
+ context=context or {},
278
+ )
279
+ self.track_error(err_data)
280
+
281
+ def track_purchase_initiated(self, data: PurchaseEventData):
282
+ """Track a purchase initiation (fire-and-forget)"""
283
+ data.status = PurchaseStatus.INITIATED
284
+ self._track("purchase_initiated", self._clean_dict(asdict(data)))
285
+
286
+ def track_purchase_completed(self, data: PurchaseEventData):
287
+ """Track a completed purchase (fire-and-forget)"""
288
+ data.status = PurchaseStatus.COMPLETED
289
+ self._track("purchase_completed", self._clean_dict(asdict(data)))
290
+
291
+ def track_purchase_error(self, data: PurchaseEventData):
292
+ """Track a failed purchase (fire-and-forget)"""
293
+ data.status = PurchaseStatus.FAILED
294
+ self._track("purchase_error", self._clean_dict(asdict(data)))
295
+
296
+ def track_recurring_charge_success(self, data: RecurringChargeEventData):
297
+ """Track a successful recurring charge (fire-and-forget)"""
298
+ data.is_success = True
299
+ self._track("recurring_charge_success", self._clean_dict(asdict(data)))
300
+
301
+ def track_recurring_charge_failed(self, data: RecurringChargeEventData):
302
+ """Track a failed recurring charge (fire-and-forget)"""
303
+ data.is_success = False
304
+ self._track("recurring_charge_failed", self._clean_dict(asdict(data)))
305
+
306
+ async def identify(self, data: UserIdentifyData):
307
+ """Identify a user with properties"""
308
+ if not self._session:
309
+ logger.error("Client not started. Call start() first.")
310
+ return
311
+
312
+ payload = self._clean_dict(asdict(data))
313
+
314
+ try:
315
+ async with self._session.post(
316
+ f"{self.api_url}/api/users/identify",
317
+ json={"botId": self.bot_id, **payload},
318
+ headers={"X-API-Key": self.api_key},
319
+ ) as response:
320
+ if response.status != 200:
321
+ logger.warning(f"Identify returned status {response.status}")
322
+
323
+ except Exception as e:
324
+ logger.error(f"Error identifying user: {e}")
325
+
326
+ async def flush(self):
327
+ """Manually flush all pending events"""
328
+ if not self.batch_events:
329
+ return
330
+
331
+ events = []
332
+ while not self._queue.empty():
333
+ try:
334
+ events.append(self._queue.get_nowait())
335
+ except asyncio.QueueEmpty:
336
+ break
337
+
338
+ await self._flush_events(events)
339
+
340
+ async def shutdown(self):
341
+ """Shutdown the client and flush remaining events"""
342
+ self._shutdown_event.set()
343
+
344
+ if self._worker_task:
345
+ self._worker_task.cancel()
346
+ try:
347
+ await self._worker_task
348
+ except asyncio.CancelledError:
349
+ pass
350
+
351
+ # Flush any remaining events
352
+ await self.flush()
353
+
354
+ if self._session:
355
+ await self._session.close()
356
+
357
+ logger.debug("AsyncMetricsFirst client shutdown complete")
358
+
359
+ async def __aenter__(self):
360
+ await self.start()
361
+ return self
362
+
363
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
364
+ await self.shutdown()
365
+
metricsfirst/client.py ADDED
@@ -0,0 +1,353 @@
1
+ """
2
+ Synchronous MetricsFirst client
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ import time
9
+ from dataclasses import asdict
10
+ from queue import Queue, Empty
11
+ from typing import Optional, List, Dict, Any
12
+ from urllib.request import Request, urlopen
13
+ from urllib.error import URLError, HTTPError
14
+
15
+ from .types import (
16
+ ServiceEventData,
17
+ ErrorEventData,
18
+ PurchaseEventData,
19
+ PurchaseStatus,
20
+ RecurringChargeEventData,
21
+ UserIdentifyData,
22
+ )
23
+
24
+ logger = logging.getLogger("metricsfirst")
25
+
26
+
27
+ class MetricsFirst:
28
+ """
29
+ Synchronous MetricsFirst SDK client.
30
+
31
+ Note: Commands and interactions are tracked automatically.
32
+ Use this SDK to track custom events like services, purchases, and errors.
33
+
34
+ Usage:
35
+ mf = MetricsFirst(bot_id="your_bot_id", api_key="your_api_key")
36
+ mf.track_service(ServiceEventData(user_id=123, service_name="generation"))
37
+ mf.shutdown() # Call on exit to flush remaining events
38
+ """
39
+
40
+ DEFAULT_API_URL = "https://metricsfirst.io/api"
41
+
42
+ def __init__(
43
+ self,
44
+ bot_id: str,
45
+ api_key: str,
46
+ api_url: Optional[str] = None,
47
+ batch_events: bool = True,
48
+ batch_size: int = 10,
49
+ batch_interval: float = 5.0,
50
+ debug: bool = False,
51
+ timeout: float = 10.0,
52
+ ):
53
+ """
54
+ Initialize the MetricsFirst client.
55
+
56
+ Args:
57
+ bot_id: Your bot's unique identifier
58
+ api_key: Your API key from MetricsFirst dashboard
59
+ api_url: Custom API URL (optional)
60
+ batch_events: Whether to batch events before sending
61
+ batch_size: Number of events per batch
62
+ batch_interval: Seconds between batch flushes
63
+ debug: Enable debug logging
64
+ timeout: HTTP request timeout in seconds
65
+ """
66
+ self.bot_id = bot_id
67
+ self.api_key = api_key
68
+ self.api_url = (api_url or self.DEFAULT_API_URL).rstrip("/")
69
+ self.batch_events = batch_events
70
+ self.batch_size = batch_size
71
+ self.batch_interval = batch_interval
72
+ self.timeout = timeout
73
+
74
+ if debug:
75
+ logging.basicConfig(level=logging.DEBUG)
76
+ logger.setLevel(logging.DEBUG)
77
+
78
+ # Max 1000 events in queue to prevent memory issues
79
+ self._queue: Queue = Queue(maxsize=1000)
80
+ self._shutdown = threading.Event()
81
+ self._worker_thread: Optional[threading.Thread] = None
82
+
83
+ if batch_events:
84
+ self._start_worker()
85
+
86
+ def _start_worker(self):
87
+ """Start the background worker thread for batching"""
88
+ self._worker_thread = threading.Thread(target=self._worker, daemon=True)
89
+ self._worker_thread.start()
90
+
91
+ def _worker(self):
92
+ """Background worker that flushes events periodically"""
93
+ events: List[Dict[str, Any]] = []
94
+ last_flush = time.time()
95
+
96
+ while not self._shutdown.is_set():
97
+ try:
98
+ # Try to get an event with timeout
99
+ event = self._queue.get(timeout=0.1)
100
+ events.append(event)
101
+
102
+ # Flush if batch is full
103
+ if len(events) >= self.batch_size:
104
+ self._flush_events(events)
105
+ events = []
106
+ last_flush = time.time()
107
+ except Empty:
108
+ pass
109
+
110
+ # Flush if interval has passed
111
+ if events and (time.time() - last_flush) >= self.batch_interval:
112
+ self._flush_events(events)
113
+ events = []
114
+ last_flush = time.time()
115
+
116
+ # Flush remaining events on shutdown
117
+ while not self._queue.empty():
118
+ try:
119
+ events.append(self._queue.get_nowait())
120
+ except Empty:
121
+ break
122
+
123
+ if events:
124
+ self._flush_events(events)
125
+
126
+ def _flush_events(self, events: List[Dict[str, Any]]):
127
+ """Send a batch of events to the API"""
128
+ if not events:
129
+ return
130
+
131
+ try:
132
+ for event in events:
133
+ self._send_event(event["type"], event["data"])
134
+ except Exception as e:
135
+ logger.error(f"Failed to flush events: {e}")
136
+
137
+ def _send_event(self, event_type: str, data: Dict[str, Any]):
138
+ """Send a single event to the API"""
139
+ payload = {
140
+ "botId": self.bot_id,
141
+ "type": event_type,
142
+ "data": data,
143
+ }
144
+
145
+ # URL: base/analytics/track (base already includes /api if provided)
146
+ url = f"{self.api_url}/analytics/track"
147
+
148
+ try:
149
+ request = Request(
150
+ url,
151
+ data=json.dumps(payload).encode("utf-8"),
152
+ headers={
153
+ "Content-Type": "application/json",
154
+ "X-API-Key": self.api_key,
155
+ },
156
+ method="POST",
157
+ )
158
+
159
+ with urlopen(request, timeout=self.timeout) as response:
160
+ if response.status != 200:
161
+ response_body = response.read().decode("utf-8")
162
+ logger.warning(f"API returned status {response.status} for {url}: {response_body}")
163
+ else:
164
+ logger.debug(f"Event sent: {event_type}")
165
+
166
+ except HTTPError as e:
167
+ # Read error response body to get detailed error message
168
+ try:
169
+ error_body = e.read().decode("utf-8")
170
+ error_data = json.loads(error_body)
171
+ error_message = error_data.get("error", e.reason)
172
+ except Exception:
173
+ error_message = e.reason
174
+ logger.warning(f"API returned status {e.code} for {url}: {error_message}")
175
+ except URLError as e:
176
+ logger.error(f"URL error sending event: {e.reason}")
177
+ except Exception as e:
178
+ logger.error(f"Error sending event: {e}")
179
+
180
+ def _track(self, event_type: str, data: Dict[str, Any]):
181
+ """Queue or send an event (non-blocking, fire-and-forget)"""
182
+ if self.batch_events:
183
+ # Non-blocking put to queue - background thread will handle sending
184
+ try:
185
+ self._queue.put_nowait({"type": event_type, "data": data})
186
+ except Exception:
187
+ logger.warning("Event queue full, dropping event")
188
+ else:
189
+ # Send in background thread to avoid blocking
190
+ threading.Thread(
191
+ target=self._send_event,
192
+ args=(event_type, data),
193
+ daemon=True
194
+ ).start()
195
+
196
+ def _clean_dict(self, d: Dict[str, Any]) -> Dict[str, Any]:
197
+ """Remove None values and convert enums"""
198
+ result = {}
199
+ for k, v in d.items():
200
+ if v is None:
201
+ continue
202
+ if hasattr(v, "value"): # Enum
203
+ result[k] = v.value
204
+ elif isinstance(v, dict):
205
+ result[k] = self._clean_dict(v)
206
+ else:
207
+ result[k] = v
208
+ return result
209
+
210
+ # ==========================================
211
+ # Tracking Methods (fire-and-forget, non-blocking)
212
+ # Note: Commands/interactions are tracked automatically
213
+ # ==========================================
214
+
215
+ def track(
216
+ self,
217
+ user_id: int,
218
+ event_name: str,
219
+ properties: Optional[Dict[str, Any]] = None,
220
+ ):
221
+ """
222
+ Track a custom event with dynamic properties.
223
+
224
+ Args:
225
+ user_id: User ID (Telegram user ID)
226
+ event_name: Name of the event (e.g., 'STORY_RESPONSE', 'BUTTON_CLICK')
227
+ properties: Dictionary of event properties (any key-value pairs)
228
+
229
+ Example:
230
+ mf.track(123456, 'STORY_RESPONSE', {
231
+ 'target': 'username123',
232
+ 'url': 'https://example.com/story',
233
+ 'response_time_ms': 150,
234
+ 'success': True
235
+ })
236
+ """
237
+ data = {
238
+ "eventName": event_name,
239
+ "properties": properties or {},
240
+ "userId": user_id,
241
+ "lib": "python",
242
+ "libVersion": "1.0.0",
243
+ }
244
+
245
+ self._track("custom", data)
246
+
247
+ def track_service(self, data: ServiceEventData):
248
+ """Track a service provided"""
249
+ self._track("service_provided", self._clean_dict(asdict(data)))
250
+
251
+ def track_error(self, data: ErrorEventData):
252
+ """Track an error"""
253
+ self._track("error", self._clean_dict(asdict(data)))
254
+
255
+ def track_error_from_exception(
256
+ self,
257
+ exception: Exception,
258
+ user_id: Optional[int] = None,
259
+ severity: str = "error",
260
+ context: Optional[Dict[str, Any]] = None,
261
+ ):
262
+ """Track an error from an exception"""
263
+ import traceback
264
+
265
+ data = ErrorEventData(
266
+ error_type=type(exception).__name__,
267
+ error_message=str(exception),
268
+ user_id=user_id,
269
+ error_stack=traceback.format_exc(),
270
+ severity=severity,
271
+ context=context or {},
272
+ )
273
+ self.track_error(data)
274
+
275
+ def track_purchase_initiated(self, data: PurchaseEventData):
276
+ """Track a purchase initiation"""
277
+ data.status = PurchaseStatus.INITIATED
278
+ self._track("purchase_initiated", self._clean_dict(asdict(data)))
279
+
280
+ def track_purchase_completed(self, data: PurchaseEventData):
281
+ """Track a completed purchase"""
282
+ data.status = PurchaseStatus.COMPLETED
283
+ self._track("purchase_completed", self._clean_dict(asdict(data)))
284
+
285
+ def track_purchase_error(self, data: PurchaseEventData):
286
+ """Track a failed purchase"""
287
+ data.status = PurchaseStatus.FAILED
288
+ self._track("purchase_error", self._clean_dict(asdict(data)))
289
+
290
+ def track_recurring_charge_success(self, data: RecurringChargeEventData):
291
+ """Track a successful recurring charge"""
292
+ data.is_success = True
293
+ self._track("recurring_charge_success", self._clean_dict(asdict(data)))
294
+
295
+ def track_recurring_charge_failed(self, data: RecurringChargeEventData):
296
+ """Track a failed recurring charge"""
297
+ data.is_success = False
298
+ self._track("recurring_charge_failed", self._clean_dict(asdict(data)))
299
+
300
+ def identify(self, data: UserIdentifyData):
301
+ """Identify a user with properties"""
302
+ payload = self._clean_dict(asdict(data))
303
+
304
+ try:
305
+ request = Request(
306
+ f"{self.api_url}/api/users/identify",
307
+ data=json.dumps({
308
+ "botId": self.bot_id,
309
+ **payload,
310
+ }).encode("utf-8"),
311
+ headers={
312
+ "Content-Type": "application/json",
313
+ "X-API-Key": self.api_key,
314
+ },
315
+ method="POST",
316
+ )
317
+
318
+ with urlopen(request, timeout=self.timeout) as response:
319
+ if response.status != 200:
320
+ logger.warning(f"Identify returned status {response.status}")
321
+
322
+ except Exception as e:
323
+ logger.error(f"Error identifying user: {e}")
324
+
325
+ def flush(self):
326
+ """Manually flush all pending events"""
327
+ if not self.batch_events:
328
+ return
329
+
330
+ events = []
331
+ while not self._queue.empty():
332
+ try:
333
+ events.append(self._queue.get_nowait())
334
+ except Empty:
335
+ break
336
+
337
+ self._flush_events(events)
338
+
339
+ def shutdown(self):
340
+ """Shutdown the client and flush remaining events"""
341
+ self._shutdown.set()
342
+
343
+ if self._worker_thread and self._worker_thread.is_alive():
344
+ self._worker_thread.join(timeout=5.0)
345
+
346
+ logger.debug("MetricsFirst client shutdown complete")
347
+
348
+ def __enter__(self):
349
+ return self
350
+
351
+ def __exit__(self, exc_type, exc_val, exc_tb):
352
+ self.shutdown()
353
+
metricsfirst/py.typed ADDED
File without changes
metricsfirst/types.py ADDED
@@ -0,0 +1,131 @@
1
+ """
2
+ Type definitions for MetricsFirst SDK
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Optional, Dict, Any
8
+
9
+
10
+ class InteractionType(str, Enum):
11
+ MESSAGE = "message"
12
+ CALLBACK_QUERY = "callback_query"
13
+ INLINE_QUERY = "inline_query"
14
+ COMMAND = "command"
15
+ PHOTO = "photo"
16
+ VIDEO = "video"
17
+ DOCUMENT = "document"
18
+ VOICE = "voice"
19
+ STICKER = "sticker"
20
+ OTHER = "other"
21
+
22
+
23
+ class ErrorSeverity(str, Enum):
24
+ DEBUG = "debug"
25
+ INFO = "info"
26
+ WARNING = "warning"
27
+ ERROR = "error"
28
+ CRITICAL = "critical"
29
+
30
+
31
+ class PurchaseStatus(str, Enum):
32
+ INITIATED = "initiated"
33
+ COMPLETED = "completed"
34
+ FAILED = "failed"
35
+ REFUNDED = "refunded"
36
+
37
+
38
+ @dataclass
39
+ class CommandEventData:
40
+ """Data for tracking bot commands"""
41
+ user_id: int
42
+ command: str
43
+ command_args: Optional[str] = None
44
+ response_time_ms: Optional[int] = None
45
+ is_success: bool = True
46
+ error_message: Optional[str] = None
47
+
48
+
49
+ @dataclass
50
+ class InteractionEventData:
51
+ """Data for tracking user interactions"""
52
+ user_id: int
53
+ interaction_type: InteractionType = InteractionType.MESSAGE
54
+ content_preview: Optional[str] = None
55
+ callback_data: Optional[str] = None
56
+ response_time_ms: Optional[int] = None
57
+
58
+
59
+ @dataclass
60
+ class ServiceEventData:
61
+ """Data for tracking services provided"""
62
+ user_id: int
63
+ service_name: str
64
+ service_type: Optional[str] = None
65
+ is_free: bool = True
66
+ price: float = 0.0
67
+ currency: str = "USD"
68
+ duration_ms: Optional[int] = None
69
+ is_success: bool = True
70
+ error_message: Optional[str] = None
71
+ metadata: Dict[str, Any] = field(default_factory=dict)
72
+
73
+
74
+ @dataclass
75
+ class ErrorEventData:
76
+ """Data for tracking errors"""
77
+ error_type: str
78
+ error_message: str
79
+ user_id: Optional[int] = None
80
+ error_stack: Optional[str] = None
81
+ error_code: Optional[str] = None
82
+ severity: ErrorSeverity = ErrorSeverity.ERROR
83
+ context: Dict[str, Any] = field(default_factory=dict)
84
+
85
+
86
+ @dataclass
87
+ class PurchaseEventData:
88
+ """Data for tracking purchases"""
89
+ user_id: int
90
+ purchase_id: str
91
+ price: float
92
+ status: PurchaseStatus = PurchaseStatus.INITIATED
93
+ tariff_id: Optional[str] = None
94
+ tariff_name: Optional[str] = None
95
+ currency: str = "USD"
96
+ is_recurrent: bool = False
97
+ recurrence_period: Optional[str] = None # "daily", "weekly", "monthly", "yearly"
98
+ payment_method: Optional[str] = None
99
+ payment_provider: Optional[str] = None
100
+ discount_percent: float = 0.0
101
+ coupon_code: Optional[str] = None
102
+ error_message: Optional[str] = None
103
+ metadata: Dict[str, Any] = field(default_factory=dict)
104
+
105
+
106
+ @dataclass
107
+ class RecurringChargeEventData:
108
+ """Data for tracking recurring subscription charges"""
109
+ user_id: int
110
+ subscription_id: str
111
+ amount: float
112
+ is_success: bool = True
113
+ currency: str = "USD"
114
+ payment_method: Optional[str] = None
115
+ charge_attempt: int = 1
116
+ error_code: Optional[str] = None
117
+ error_message: Optional[str] = None
118
+ next_retry_at: Optional[str] = None
119
+
120
+
121
+ @dataclass
122
+ class UserIdentifyData:
123
+ """Data for identifying users"""
124
+ user_id: int
125
+ username: Optional[str] = None
126
+ first_name: Optional[str] = None
127
+ last_name: Optional[str] = None
128
+ language_code: Optional[str] = None
129
+ is_premium: bool = False
130
+ properties: Dict[str, Any] = field(default_factory=dict)
131
+
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.4
2
+ Name: metricsfirst
3
+ Version: 0.1.7
4
+ Summary: MetricsFirst SDK for Python - Analytics for Telegram bots
5
+ Project-URL: Homepage, https://metricsfirst.com
6
+ Project-URL: Documentation, https://docs.metricsfirst.com/python
7
+ Project-URL: Repository, https://github.com/metricsfirst/metricsfirst-python
8
+ Project-URL: Changelog, https://github.com/metricsfirst/metricsfirst-python/blob/main/CHANGELOG.md
9
+ Author-email: MetricsFirst <support@metricsfirst.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: analytics,bot,metrics,metricsfirst,telegram,tracking
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.8
26
+ Provides-Extra: async
27
+ Requires-Dist: aiohttp>=3.8.0; extra == 'async'
28
+ Provides-Extra: dev
29
+ Requires-Dist: aiohttp>=3.8.0; extra == 'dev'
30
+ Requires-Dist: black>=23.0.0; extra == 'dev'
31
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
32
+ Requires-Dist: pytest-asyncio>=0.20.0; extra == 'dev'
33
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
34
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # MetricsFirst Python SDK
38
+
39
+ Official Python SDK for [MetricsFirst](https://metricsfirst.com) - Analytics for Telegram bots.
40
+
41
+ > **Note:** Commands and interactions are tracked automatically when you add your bot to MetricsFirst. This SDK is for tracking custom events like services, purchases, and errors.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ # Basic installation (sync only)
47
+ pip install metricsfirst
48
+
49
+ # With async support
50
+ pip install metricsfirst[async]
51
+ ```
52
+
53
+ ## Features
54
+
55
+ - **Fire-and-forget**: All tracking calls are non-blocking and don't add latency
56
+ - **Background sending**: Events are sent in a separate thread (sync) or task (async)
57
+ - **Error resilience**: Errors are logged, never thrown to your code
58
+ - **Memory safe**: Queue is limited to 1000 events to prevent memory issues
59
+ - **Custom Events**: Track any event with dynamic properties (Mixpanel-style)
60
+
61
+ ## Quick Start
62
+
63
+ ### Custom Events (Mixpanel-style)
64
+
65
+ Track any event with dynamic properties:
66
+
67
+ ```python
68
+ from metricsfirst import MetricsFirst
69
+
70
+ mf = MetricsFirst(
71
+ bot_id="your_bot_id",
72
+ api_key="your_api_key",
73
+ )
74
+
75
+ # Track custom events with any properties
76
+ mf.track(123456789, 'STORY_RESPONSE', {
77
+ 'target': 'username123',
78
+ 'url': 'https://example.com/story',
79
+ 'response_time_ms': 150,
80
+ 'success': True,
81
+ })
82
+
83
+ mf.track(123456789, 'BUTTON_CLICK', {
84
+ 'button_name': 'premium_upgrade',
85
+ 'screen': 'main_menu',
86
+ })
87
+
88
+ mf.track(123456789, 'VIDEO_DOWNLOADED', {
89
+ 'duration_seconds': 45,
90
+ 'quality': '1080p',
91
+ 'source': 'instagram',
92
+ })
93
+
94
+ mf.shutdown()
95
+ ```
96
+
97
+ ### Synchronous Client
98
+
99
+ ```python
100
+ from metricsfirst import MetricsFirst, ServiceEventData
101
+
102
+ # Initialize
103
+ mf = MetricsFirst(
104
+ bot_id="your_bot_id",
105
+ api_key="your_api_key",
106
+ )
107
+
108
+ # Track a service (fire-and-forget, non-blocking)
109
+ mf.track_service(ServiceEventData(
110
+ user_id=123456789,
111
+ service_name="image_generation",
112
+ is_free=False,
113
+ price=10,
114
+ currency="USD",
115
+ ))
116
+
117
+ # Shutdown flushes remaining events
118
+ mf.shutdown()
119
+ ```
120
+
121
+ ### Asynchronous Client
122
+
123
+ ```python
124
+ import asyncio
125
+ from metricsfirst import AsyncMetricsFirst, ServiceEventData
126
+
127
+ async def main():
128
+ # Initialize
129
+ mf = AsyncMetricsFirst(
130
+ bot_id="your_bot_id",
131
+ api_key="your_api_key",
132
+ )
133
+ await mf.start()
134
+
135
+ # Track events (fire-and-forget)
136
+ await mf.track_service(ServiceEventData(
137
+ user_id=123456789,
138
+ service_name="image_generation",
139
+ ))
140
+
141
+ # Shutdown
142
+ await mf.shutdown()
143
+
144
+ asyncio.run(main())
145
+ ```
146
+
147
+ ### Context Manager
148
+
149
+ ```python
150
+ # Sync
151
+ with MetricsFirst(bot_id="...", api_key="...") as mf:
152
+ mf.track_service(...)
153
+
154
+ # Async
155
+ async with AsyncMetricsFirst(bot_id="...", api_key="...") as mf:
156
+ await mf.track_service(...)
157
+ ```
158
+
159
+ ## Available Methods
160
+
161
+ | Method | Description |
162
+ | ---------------------------------- | ------------------------------------------- |
163
+ | `track()` | **Track custom events with any properties** |
164
+ | `track_service()` | Track services provided |
165
+ | `track_error()` | Track errors |
166
+ | `track_error_from_exception()` | Track error from exception |
167
+ | `track_purchase_initiated()` | Track purchase start |
168
+ | `track_purchase_completed()` | Track successful purchase |
169
+ | `track_purchase_error()` | Track failed purchase |
170
+ | `track_recurring_charge_success()` | Track subscription charge |
171
+ | `track_recurring_charge_failed()` | Track failed charge |
172
+ | `identify()` | Identify user with properties |
173
+
174
+ ### track() - Custom Events
175
+
176
+ ```python
177
+ mf.track(
178
+ user_id: int, # Telegram user ID
179
+ event_name: str, # Event name (e.g., 'STORY_RESPONSE')
180
+ properties: dict = None, # Any key-value pairs
181
+ )
182
+ ```
183
+
184
+ ## Configuration
185
+
186
+ ```python
187
+ mf = MetricsFirst(
188
+ bot_id="your_bot_id",
189
+ api_key="your_api_key",
190
+ api_url="https://api.metricsfirst.com", # Custom API URL
191
+ batch_events=True, # Batch events before sending
192
+ batch_size=10, # Events per batch
193
+ batch_interval=5.0, # Seconds between flushes
194
+ debug=False, # Enable debug logging
195
+ timeout=10.0, # HTTP timeout
196
+ )
197
+ ```
198
+
199
+ ## License
200
+
201
+ MIT
@@ -0,0 +1,9 @@
1
+ metricsfirst/__init__.py,sha256=VLKKlbrfHZC9VNRPlaWRl8TOX-EWHo_oQ6XizZcxBvA,585
2
+ metricsfirst/async_client.py,sha256=1l7o3rJ04GqgjIzThiApgwUudFiLqjuTdyT_8SWDPmY,12610
3
+ metricsfirst/client.py,sha256=y9Mns-azaNooYKcIcSd8EKx9IJWNzeiW-aP5edNehQg,12091
4
+ metricsfirst/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ metricsfirst/types.py,sha256=YrQE_lg8IoAFRCks5wSCM6js5bdL_zh8QCJBYE1G7s8,3392
6
+ metricsfirst-0.1.7.dist-info/METADATA,sha256=iWXX-ctVf2quEY965apva3M7cnQAgVKDz_0RWdwrcNE,5996
7
+ metricsfirst-0.1.7.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ metricsfirst-0.1.7.dist-info/licenses/LICENSE,sha256=Y-1JOUpozrNJlHFFq4HOB7Fc7L7lWmnIXAARWLo9ZnE,1070
9
+ metricsfirst-0.1.7.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 MetricsFirst
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+