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.
- metricsfirst/__init__.py +30 -0
- metricsfirst/async_client.py +365 -0
- metricsfirst/client.py +353 -0
- metricsfirst/py.typed +0 -0
- metricsfirst/types.py +131 -0
- metricsfirst-0.1.7.dist-info/METADATA +201 -0
- metricsfirst-0.1.7.dist-info/RECORD +9 -0
- metricsfirst-0.1.7.dist-info/WHEEL +4 -0
- metricsfirst-0.1.7.dist-info/licenses/LICENSE +22 -0
metricsfirst/__init__.py
ADDED
|
@@ -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,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
|
+
|