semetrics-sdk 0.1.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.
- semetrics_sdk-0.1.0/PKG-INFO +108 -0
- semetrics_sdk-0.1.0/README.md +90 -0
- semetrics_sdk-0.1.0/pyproject.toml +37 -0
- semetrics_sdk-0.1.0/semetrics/__init__.py +3 -0
- semetrics_sdk-0.1.0/semetrics/client.py +84 -0
- semetrics_sdk-0.1.0/semetrics/models.py +29 -0
- semetrics_sdk-0.1.0/semetrics/queue.py +104 -0
- semetrics_sdk-0.1.0/semetrics/transport.py +35 -0
- semetrics_sdk-0.1.0/semetrics/worker.py +69 -0
- semetrics_sdk-0.1.0/semetrics_sdk.egg-info/PKG-INFO +108 -0
- semetrics_sdk-0.1.0/semetrics_sdk.egg-info/SOURCES.txt +19 -0
- semetrics_sdk-0.1.0/semetrics_sdk.egg-info/dependency_links.txt +1 -0
- semetrics_sdk-0.1.0/semetrics_sdk.egg-info/requires.txt +1 -0
- semetrics_sdk-0.1.0/semetrics_sdk.egg-info/top_level.txt +1 -0
- semetrics_sdk-0.1.0/setup.cfg +4 -0
- semetrics_sdk-0.1.0/tests/test_client.py +98 -0
- semetrics_sdk-0.1.0/tests/test_integration.py +124 -0
- semetrics_sdk-0.1.0/tests/test_models.py +46 -0
- semetrics_sdk-0.1.0/tests/test_queue.py +154 -0
- semetrics_sdk-0.1.0/tests/test_transport.py +101 -0
- semetrics_sdk-0.1.0/tests/test_worker.py +131 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semetrics-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Semetrics — AI-first product analytics platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://semetrics.ru
|
|
7
|
+
Project-URL: Repository, https://github.com/trombocit/semetrics-python
|
|
8
|
+
Keywords: analytics,tracking,semetrics,product-analytics
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: httpx>=0.28
|
|
18
|
+
|
|
19
|
+
# Semetrics Python SDK
|
|
20
|
+
|
|
21
|
+
Python SDK для отправки аналитических событий на платформу Semetrics.
|
|
22
|
+
|
|
23
|
+
## Установка
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install semetrics-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Быстрый старт
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from semetrics import Semetrics
|
|
33
|
+
|
|
34
|
+
# Инициализация (один раз при старте приложения)
|
|
35
|
+
semetrics = Semetrics(
|
|
36
|
+
api_key="sm_live_ваш_ключ",
|
|
37
|
+
endpoint="https://semetrics.ru/events",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Отправка событий (не блокирует — очередь + фоновый поток)
|
|
41
|
+
semetrics.track(
|
|
42
|
+
event_name="user_signed_up",
|
|
43
|
+
user_id="user_123",
|
|
44
|
+
properties={"plan": "pro", "source": "organic"},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
semetrics.track(
|
|
48
|
+
event_name="checkout_completed",
|
|
49
|
+
user_id="user_123",
|
|
50
|
+
properties={"amount": 1990, "currency": "RUB", "items": 3},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# При завершении программы — отправить все накопленные события
|
|
54
|
+
semetrics.shutdown()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Использование через контекстный менеджер
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
with Semetrics(api_key="sm_live_...", endpoint="https://semetrics.ru/events") as sm:
|
|
61
|
+
sm.track("page_viewed", user_id="u1", properties={"page": "/home"})
|
|
62
|
+
# shutdown() вызывается автоматически при выходе из блока
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Параметры конструктора
|
|
66
|
+
|
|
67
|
+
| Параметр | По умолчанию | Описание |
|
|
68
|
+
|----------|-------------|----------|
|
|
69
|
+
| `api_key` | обязательный | API-ключ проекта (`sm_live_...`) |
|
|
70
|
+
| `endpoint` | `https://semetrics.ru/events` | URL сервиса |
|
|
71
|
+
| `flush_interval` | `5` | Интервал фонового сброса (секунды) |
|
|
72
|
+
| `batch_size` | `50` | Максимум событий в одном запросе |
|
|
73
|
+
| `max_queue_size` | `10_000` | Максимум событий в памяти |
|
|
74
|
+
| `max_retries` | `3` | Попыток при ошибке отправки |
|
|
75
|
+
| `request_timeout` | `10` | Таймаут HTTP запроса (секунды) |
|
|
76
|
+
| `persistence_path` | `None` | Путь к SQLite для персистентной очереди |
|
|
77
|
+
|
|
78
|
+
## Персистентная очередь (опционально)
|
|
79
|
+
|
|
80
|
+
Для серверных приложений с требованием "ни одно событие не должно потеряться":
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
semetrics = Semetrics(
|
|
84
|
+
api_key="sm_live_...",
|
|
85
|
+
endpoint="https://semetrics.ru/events",
|
|
86
|
+
persistence_path="/var/lib/myapp/semetrics_queue.db",
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
События сохраняются в SQLite и отправляются даже после перезапуска процесса.
|
|
91
|
+
|
|
92
|
+
## Принудительный сброс
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# Отправить всё накопленное прямо сейчас (блокирует до завершения)
|
|
96
|
+
semetrics.flush()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Поля события
|
|
100
|
+
|
|
101
|
+
| Поле | Тип | Обязательное | Описание |
|
|
102
|
+
|------|-----|-------------|----------|
|
|
103
|
+
| `event_name` | str | ✅ | Название события |
|
|
104
|
+
| `user_id` | str | — | ID аутентифицированного пользователя |
|
|
105
|
+
| `anonymous_id` | str | — | ID анонимного пользователя |
|
|
106
|
+
| `session_id` | str | — | ID сессии |
|
|
107
|
+
| `properties` | dict | — | Произвольные свойства события |
|
|
108
|
+
| `client_ts` | datetime | — | Время события (по умолчанию — `now()`) |
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Semetrics Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK для отправки аналитических событий на платформу Semetrics.
|
|
4
|
+
|
|
5
|
+
## Установка
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install semetrics-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Быстрый старт
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from semetrics import Semetrics
|
|
15
|
+
|
|
16
|
+
# Инициализация (один раз при старте приложения)
|
|
17
|
+
semetrics = Semetrics(
|
|
18
|
+
api_key="sm_live_ваш_ключ",
|
|
19
|
+
endpoint="https://semetrics.ru/events",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Отправка событий (не блокирует — очередь + фоновый поток)
|
|
23
|
+
semetrics.track(
|
|
24
|
+
event_name="user_signed_up",
|
|
25
|
+
user_id="user_123",
|
|
26
|
+
properties={"plan": "pro", "source": "organic"},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
semetrics.track(
|
|
30
|
+
event_name="checkout_completed",
|
|
31
|
+
user_id="user_123",
|
|
32
|
+
properties={"amount": 1990, "currency": "RUB", "items": 3},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# При завершении программы — отправить все накопленные события
|
|
36
|
+
semetrics.shutdown()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Использование через контекстный менеджер
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
with Semetrics(api_key="sm_live_...", endpoint="https://semetrics.ru/events") as sm:
|
|
43
|
+
sm.track("page_viewed", user_id="u1", properties={"page": "/home"})
|
|
44
|
+
# shutdown() вызывается автоматически при выходе из блока
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Параметры конструктора
|
|
48
|
+
|
|
49
|
+
| Параметр | По умолчанию | Описание |
|
|
50
|
+
|----------|-------------|----------|
|
|
51
|
+
| `api_key` | обязательный | API-ключ проекта (`sm_live_...`) |
|
|
52
|
+
| `endpoint` | `https://semetrics.ru/events` | URL сервиса |
|
|
53
|
+
| `flush_interval` | `5` | Интервал фонового сброса (секунды) |
|
|
54
|
+
| `batch_size` | `50` | Максимум событий в одном запросе |
|
|
55
|
+
| `max_queue_size` | `10_000` | Максимум событий в памяти |
|
|
56
|
+
| `max_retries` | `3` | Попыток при ошибке отправки |
|
|
57
|
+
| `request_timeout` | `10` | Таймаут HTTP запроса (секунды) |
|
|
58
|
+
| `persistence_path` | `None` | Путь к SQLite для персистентной очереди |
|
|
59
|
+
|
|
60
|
+
## Персистентная очередь (опционально)
|
|
61
|
+
|
|
62
|
+
Для серверных приложений с требованием "ни одно событие не должно потеряться":
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
semetrics = Semetrics(
|
|
66
|
+
api_key="sm_live_...",
|
|
67
|
+
endpoint="https://semetrics.ru/events",
|
|
68
|
+
persistence_path="/var/lib/myapp/semetrics_queue.db",
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
События сохраняются в SQLite и отправляются даже после перезапуска процесса.
|
|
73
|
+
|
|
74
|
+
## Принудительный сброс
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# Отправить всё накопленное прямо сейчас (блокирует до завершения)
|
|
78
|
+
semetrics.flush()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Поля события
|
|
82
|
+
|
|
83
|
+
| Поле | Тип | Обязательное | Описание |
|
|
84
|
+
|------|-----|-------------|----------|
|
|
85
|
+
| `event_name` | str | ✅ | Название события |
|
|
86
|
+
| `user_id` | str | — | ID аутентифицированного пользователя |
|
|
87
|
+
| `anonymous_id` | str | — | ID анонимного пользователя |
|
|
88
|
+
| `session_id` | str | — | ID сессии |
|
|
89
|
+
| `properties` | dict | — | Произвольные свойства события |
|
|
90
|
+
| `client_ts` | datetime | — | Время события (по умолчанию — `now()`) |
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "semetrics-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Semetrics — AI-first product analytics platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["analytics", "tracking", "semetrics", "product-analytics"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"httpx>=0.28",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://semetrics.ru"
|
|
27
|
+
Repository = "https://github.com/trombocit/semetrics-python"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["pytest>=8", "pytest-mock>=3", "respx>=0.21"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
markers = ["integration: требует локального docker-compose (запускать вручную)"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["."]
|
|
37
|
+
include = ["semetrics*"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from .models import Event
|
|
6
|
+
from .queue import EventQueue
|
|
7
|
+
from .transport import HttpTransport
|
|
8
|
+
from .worker import BackgroundWorker
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
SDK_VERSION = "0.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Semetrics:
|
|
16
|
+
"""
|
|
17
|
+
Клиент Semetrics для Python.
|
|
18
|
+
|
|
19
|
+
Пример использования:
|
|
20
|
+
semetrics = Semetrics(api_key="sm_live_...", endpoint="https://semetrics.ru/events")
|
|
21
|
+
semetrics.track("user_signed_up", user_id="u123", properties={"plan": "pro"})
|
|
22
|
+
semetrics.shutdown() # при завершении программы
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_key: str,
|
|
28
|
+
endpoint: str = "https://semetrics.ru/events",
|
|
29
|
+
flush_interval: int = 5,
|
|
30
|
+
batch_size: int = 50,
|
|
31
|
+
max_queue_size: int = 10_000,
|
|
32
|
+
max_retries: int = 3,
|
|
33
|
+
request_timeout: int = 10,
|
|
34
|
+
persistence_path: Optional[str] = None,
|
|
35
|
+
):
|
|
36
|
+
self._queue = EventQueue(max_size=max_queue_size, persistence_path=persistence_path)
|
|
37
|
+
self._transport = HttpTransport(api_key=api_key, endpoint=endpoint, timeout=request_timeout)
|
|
38
|
+
self._worker = BackgroundWorker(
|
|
39
|
+
queue=self._queue,
|
|
40
|
+
transport=self._transport,
|
|
41
|
+
flush_interval=flush_interval,
|
|
42
|
+
batch_size=batch_size,
|
|
43
|
+
max_retries=max_retries,
|
|
44
|
+
)
|
|
45
|
+
self._worker.start()
|
|
46
|
+
|
|
47
|
+
def track(
|
|
48
|
+
self,
|
|
49
|
+
event_name: str,
|
|
50
|
+
user_id: Optional[str] = None,
|
|
51
|
+
anonymous_id: Optional[str] = None,
|
|
52
|
+
session_id: Optional[str] = None,
|
|
53
|
+
properties: Optional[dict[str, Any]] = None,
|
|
54
|
+
client_ts: Optional[datetime] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Добавить событие в очередь.
|
|
58
|
+
Не блокирует вызывающий код — отправка происходит в фоне.
|
|
59
|
+
"""
|
|
60
|
+
event = Event(
|
|
61
|
+
event_name=event_name,
|
|
62
|
+
user_id=user_id,
|
|
63
|
+
anonymous_id=anonymous_id,
|
|
64
|
+
session_id=session_id,
|
|
65
|
+
platform="python",
|
|
66
|
+
sdk_version=SDK_VERSION,
|
|
67
|
+
properties=properties,
|
|
68
|
+
client_ts=client_ts or datetime.now(timezone.utc),
|
|
69
|
+
)
|
|
70
|
+
self._queue.enqueue(event)
|
|
71
|
+
|
|
72
|
+
def flush(self) -> None:
|
|
73
|
+
"""Синхронно отправить все накопленные события прямо сейчас."""
|
|
74
|
+
self._worker.flush_sync()
|
|
75
|
+
|
|
76
|
+
def shutdown(self) -> None:
|
|
77
|
+
"""Остановить фоновый worker и отправить всё накопленное перед выходом."""
|
|
78
|
+
self._worker.stop()
|
|
79
|
+
|
|
80
|
+
def __enter__(self):
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def __exit__(self, *args):
|
|
84
|
+
self.shutdown()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Event:
|
|
9
|
+
event_name: str
|
|
10
|
+
client_ts: datetime
|
|
11
|
+
user_id: Optional[str] = None
|
|
12
|
+
anonymous_id: Optional[str] = None
|
|
13
|
+
session_id: Optional[str] = None
|
|
14
|
+
platform: str = "python"
|
|
15
|
+
sdk_version: str = "0.1.0"
|
|
16
|
+
properties: Optional[dict[str, Any]] = None
|
|
17
|
+
db_id: Optional[int] = None # SQLite rowid, None для in-memory событий
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"event_name": self.event_name,
|
|
22
|
+
"user_id": self.user_id,
|
|
23
|
+
"anonymous_id": self.anonymous_id,
|
|
24
|
+
"session_id": self.session_id,
|
|
25
|
+
"platform": self.platform,
|
|
26
|
+
"sdk_version": self.sdk_version,
|
|
27
|
+
"client_ts": self.client_ts.isoformat(),
|
|
28
|
+
"properties": self.properties or {},
|
|
29
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import sqlite3
|
|
4
|
+
import threading
|
|
5
|
+
from collections import deque
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .models import Event
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventQueue:
|
|
14
|
+
"""
|
|
15
|
+
Thread-safe очередь событий.
|
|
16
|
+
По умолчанию — in-memory.
|
|
17
|
+
При указании persistence_path — дополнительно сохраняет в SQLite
|
|
18
|
+
(события переживают перезапуск процесса).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, max_size: int = 10_000, persistence_path: Optional[str] = None):
|
|
22
|
+
self._max_size = max_size
|
|
23
|
+
self._queue: deque[Event] = deque()
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
self._db: Optional[sqlite3.Connection] = None
|
|
26
|
+
|
|
27
|
+
if persistence_path:
|
|
28
|
+
self._init_db(persistence_path)
|
|
29
|
+
|
|
30
|
+
def _init_db(self, path: str) -> None:
|
|
31
|
+
self._db = sqlite3.connect(path, check_same_thread=False)
|
|
32
|
+
self._db.execute("""
|
|
33
|
+
CREATE TABLE IF NOT EXISTS queued_events (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
payload TEXT NOT NULL,
|
|
36
|
+
created_at REAL DEFAULT (unixepoch('now', 'subsec'))
|
|
37
|
+
)
|
|
38
|
+
""")
|
|
39
|
+
self._db.commit()
|
|
40
|
+
self._restore_from_db()
|
|
41
|
+
|
|
42
|
+
def _restore_from_db(self) -> None:
|
|
43
|
+
"""Загрузить непереданные события из SQLite при старте."""
|
|
44
|
+
rows = self._db.execute("SELECT id, payload FROM queued_events ORDER BY id").fetchall()
|
|
45
|
+
for row_id, payload in rows:
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(payload)
|
|
48
|
+
from datetime import datetime
|
|
49
|
+
data["client_ts"] = datetime.fromisoformat(data["client_ts"])
|
|
50
|
+
event = Event(**data)
|
|
51
|
+
event.db_id = row_id
|
|
52
|
+
self._queue.append(event)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
logger.warning(f"Не удалось восстановить событие {row_id}: {exc}")
|
|
55
|
+
if rows:
|
|
56
|
+
logger.info(f"Восстановлено {len(rows)} событий из SQLite.")
|
|
57
|
+
|
|
58
|
+
def enqueue(self, event: Event) -> bool:
|
|
59
|
+
"""Добавить событие в очередь. Возвращает False если очередь переполнена."""
|
|
60
|
+
with self._lock:
|
|
61
|
+
if len(self._queue) >= self._max_size:
|
|
62
|
+
logger.warning("Очередь переполнена, событие отброшено.")
|
|
63
|
+
return False
|
|
64
|
+
if self._db:
|
|
65
|
+
cursor = self._db.execute(
|
|
66
|
+
"INSERT INTO queued_events (payload) VALUES (?)",
|
|
67
|
+
(json.dumps(event.to_dict()),),
|
|
68
|
+
)
|
|
69
|
+
self._db.commit()
|
|
70
|
+
event.db_id = cursor.lastrowid
|
|
71
|
+
self._queue.append(event)
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
def dequeue_batch(self, size: int) -> list[Event]:
|
|
75
|
+
"""Извлечь до `size` событий из начала очереди."""
|
|
76
|
+
with self._lock:
|
|
77
|
+
batch = []
|
|
78
|
+
for _ in range(min(size, len(self._queue))):
|
|
79
|
+
batch.append(self._queue.popleft())
|
|
80
|
+
return batch
|
|
81
|
+
|
|
82
|
+
def requeue_batch(self, events: list[Event]) -> None:
|
|
83
|
+
"""Вернуть события в начало очереди (после неудачной отправки)."""
|
|
84
|
+
with self._lock:
|
|
85
|
+
for event in reversed(events):
|
|
86
|
+
self._queue.appendleft(event)
|
|
87
|
+
|
|
88
|
+
def delete_from_db(self, events: list[Event]) -> None:
|
|
89
|
+
"""Удалить отправленные события из SQLite по db_id."""
|
|
90
|
+
if not self._db:
|
|
91
|
+
return
|
|
92
|
+
ids = [e.db_id for e in events if e.db_id is not None]
|
|
93
|
+
if not ids:
|
|
94
|
+
return
|
|
95
|
+
with self._lock:
|
|
96
|
+
self._db.execute(
|
|
97
|
+
f"DELETE FROM queued_events WHERE id IN ({','.join('?' * len(ids))})",
|
|
98
|
+
ids,
|
|
99
|
+
)
|
|
100
|
+
self._db.commit()
|
|
101
|
+
|
|
102
|
+
def __len__(self) -> int:
|
|
103
|
+
with self._lock:
|
|
104
|
+
return len(self._queue)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .models import Event
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HttpTransport:
|
|
11
|
+
def __init__(self, api_key: str, endpoint: str, timeout: int = 10):
|
|
12
|
+
self._api_key = api_key
|
|
13
|
+
self._batch_url = endpoint.rstrip("/") + "/ingest/batch"
|
|
14
|
+
self._timeout = timeout
|
|
15
|
+
|
|
16
|
+
def send_batch(self, events: list[Event]) -> None:
|
|
17
|
+
"""
|
|
18
|
+
Отправить батч событий на сервер.
|
|
19
|
+
Поднимает исключение при ошибке (для retry-логики в worker'е).
|
|
20
|
+
"""
|
|
21
|
+
payload = {"events": [e.to_dict() for e in events]}
|
|
22
|
+
|
|
23
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
24
|
+
response = client.post(
|
|
25
|
+
self._batch_url,
|
|
26
|
+
json=payload,
|
|
27
|
+
headers={"X-API-Key": self._api_key},
|
|
28
|
+
)
|
|
29
|
+
response.raise_for_status()
|
|
30
|
+
|
|
31
|
+
data = response.json()
|
|
32
|
+
if data.get("status", {}).get("code") not in ("", None):
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
f"Сервер вернул ошибку: {data['status'].get('message')}"
|
|
35
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from .queue import EventQueue
|
|
6
|
+
from .transport import HttpTransport
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BackgroundWorker(threading.Thread):
|
|
12
|
+
"""
|
|
13
|
+
Daemon thread, который периодически отправляет события из очереди.
|
|
14
|
+
Запускается при создании клиента, останавливается при shutdown().
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
queue: EventQueue,
|
|
20
|
+
transport: HttpTransport,
|
|
21
|
+
flush_interval: int = 5,
|
|
22
|
+
batch_size: int = 50,
|
|
23
|
+
max_retries: int = 3,
|
|
24
|
+
):
|
|
25
|
+
super().__init__(daemon=True, name="semetrics-worker")
|
|
26
|
+
self._queue = queue
|
|
27
|
+
self._transport = transport
|
|
28
|
+
self._flush_interval = flush_interval
|
|
29
|
+
self._batch_size = batch_size
|
|
30
|
+
self._max_retries = max_retries
|
|
31
|
+
self._stop_event = threading.Event()
|
|
32
|
+
|
|
33
|
+
def run(self) -> None:
|
|
34
|
+
while not self._stop_event.is_set():
|
|
35
|
+
self._stop_event.wait(timeout=self._flush_interval)
|
|
36
|
+
self._flush()
|
|
37
|
+
|
|
38
|
+
def flush_sync(self) -> None:
|
|
39
|
+
"""Отправить всё что есть прямо сейчас (вызывается из основного потока)."""
|
|
40
|
+
while len(self._queue) > 0:
|
|
41
|
+
self._flush()
|
|
42
|
+
|
|
43
|
+
def stop(self) -> None:
|
|
44
|
+
"""Остановить worker и отправить оставшиеся события."""
|
|
45
|
+
self._stop_event.set()
|
|
46
|
+
self.flush_sync()
|
|
47
|
+
|
|
48
|
+
def _flush(self) -> None:
|
|
49
|
+
batch = self._queue.dequeue_batch(self._batch_size)
|
|
50
|
+
if not batch:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
for attempt in range(self._max_retries):
|
|
54
|
+
try:
|
|
55
|
+
self._transport.send_batch(batch)
|
|
56
|
+
self._queue.delete_from_db(batch)
|
|
57
|
+
logger.debug(f"Отправлено {len(batch)} событий.")
|
|
58
|
+
return
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
wait = 2 ** attempt # 1s, 2s, 4s
|
|
61
|
+
logger.warning(
|
|
62
|
+
f"Ошибка отправки (попытка {attempt + 1}/{self._max_retries}): {exc}. "
|
|
63
|
+
f"Повтор через {wait}с."
|
|
64
|
+
)
|
|
65
|
+
if attempt < self._max_retries - 1:
|
|
66
|
+
time.sleep(wait)
|
|
67
|
+
|
|
68
|
+
# После всех попыток — логируем потерю батча
|
|
69
|
+
logger.error(f"Батч из {len(batch)} событий потерян после {self._max_retries} попыток.")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semetrics-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Semetrics — AI-first product analytics platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://semetrics.ru
|
|
7
|
+
Project-URL: Repository, https://github.com/trombocit/semetrics-python
|
|
8
|
+
Keywords: analytics,tracking,semetrics,product-analytics
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: httpx>=0.28
|
|
18
|
+
|
|
19
|
+
# Semetrics Python SDK
|
|
20
|
+
|
|
21
|
+
Python SDK для отправки аналитических событий на платформу Semetrics.
|
|
22
|
+
|
|
23
|
+
## Установка
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install semetrics-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Быстрый старт
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from semetrics import Semetrics
|
|
33
|
+
|
|
34
|
+
# Инициализация (один раз при старте приложения)
|
|
35
|
+
semetrics = Semetrics(
|
|
36
|
+
api_key="sm_live_ваш_ключ",
|
|
37
|
+
endpoint="https://semetrics.ru/events",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Отправка событий (не блокирует — очередь + фоновый поток)
|
|
41
|
+
semetrics.track(
|
|
42
|
+
event_name="user_signed_up",
|
|
43
|
+
user_id="user_123",
|
|
44
|
+
properties={"plan": "pro", "source": "organic"},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
semetrics.track(
|
|
48
|
+
event_name="checkout_completed",
|
|
49
|
+
user_id="user_123",
|
|
50
|
+
properties={"amount": 1990, "currency": "RUB", "items": 3},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# При завершении программы — отправить все накопленные события
|
|
54
|
+
semetrics.shutdown()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Использование через контекстный менеджер
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
with Semetrics(api_key="sm_live_...", endpoint="https://semetrics.ru/events") as sm:
|
|
61
|
+
sm.track("page_viewed", user_id="u1", properties={"page": "/home"})
|
|
62
|
+
# shutdown() вызывается автоматически при выходе из блока
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Параметры конструктора
|
|
66
|
+
|
|
67
|
+
| Параметр | По умолчанию | Описание |
|
|
68
|
+
|----------|-------------|----------|
|
|
69
|
+
| `api_key` | обязательный | API-ключ проекта (`sm_live_...`) |
|
|
70
|
+
| `endpoint` | `https://semetrics.ru/events` | URL сервиса |
|
|
71
|
+
| `flush_interval` | `5` | Интервал фонового сброса (секунды) |
|
|
72
|
+
| `batch_size` | `50` | Максимум событий в одном запросе |
|
|
73
|
+
| `max_queue_size` | `10_000` | Максимум событий в памяти |
|
|
74
|
+
| `max_retries` | `3` | Попыток при ошибке отправки |
|
|
75
|
+
| `request_timeout` | `10` | Таймаут HTTP запроса (секунды) |
|
|
76
|
+
| `persistence_path` | `None` | Путь к SQLite для персистентной очереди |
|
|
77
|
+
|
|
78
|
+
## Персистентная очередь (опционально)
|
|
79
|
+
|
|
80
|
+
Для серверных приложений с требованием "ни одно событие не должно потеряться":
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
semetrics = Semetrics(
|
|
84
|
+
api_key="sm_live_...",
|
|
85
|
+
endpoint="https://semetrics.ru/events",
|
|
86
|
+
persistence_path="/var/lib/myapp/semetrics_queue.db",
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
События сохраняются в SQLite и отправляются даже после перезапуска процесса.
|
|
91
|
+
|
|
92
|
+
## Принудительный сброс
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# Отправить всё накопленное прямо сейчас (блокирует до завершения)
|
|
96
|
+
semetrics.flush()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Поля события
|
|
100
|
+
|
|
101
|
+
| Поле | Тип | Обязательное | Описание |
|
|
102
|
+
|------|-----|-------------|----------|
|
|
103
|
+
| `event_name` | str | ✅ | Название события |
|
|
104
|
+
| `user_id` | str | — | ID аутентифицированного пользователя |
|
|
105
|
+
| `anonymous_id` | str | — | ID анонимного пользователя |
|
|
106
|
+
| `session_id` | str | — | ID сессии |
|
|
107
|
+
| `properties` | dict | — | Произвольные свойства события |
|
|
108
|
+
| `client_ts` | datetime | — | Время события (по умолчанию — `now()`) |
|