trodo-python 1.0.0__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.
- trodo/__init__.py +134 -0
- trodo/api/__init__.py +0 -0
- trodo/api/async_client.py +96 -0
- trodo/api/endpoints.py +20 -0
- trodo/api/http_client.py +87 -0
- trodo/auto/__init__.py +0 -0
- trodo/auto/auto_event_manager.py +134 -0
- trodo/client.py +195 -0
- trodo/managers/__init__.py +0 -0
- trodo/managers/group_manager.py +106 -0
- trodo/managers/people_manager.py +77 -0
- trodo/queue/__init__.py +0 -0
- trodo/queue/batch_flusher.py +52 -0
- trodo/queue/event_queue.py +32 -0
- trodo/session/__init__.py +0 -0
- trodo/session/server_session.py +74 -0
- trodo/session/session_manager.py +74 -0
- trodo/types.py +79 -0
- trodo/user_context.py +224 -0
- trodo_python-1.0.0.dist-info/METADATA +227 -0
- trodo_python-1.0.0.dist-info/RECORD +23 -0
- trodo_python-1.0.0.dist-info/WHEEL +5 -0
- trodo_python-1.0.0.dist-info/top_level.txt +1 -0
trodo/__init__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
trodo-python — Trodo Analytics SDK for Python
|
|
3
|
+
|
|
4
|
+
Usage (module-level singleton):
|
|
5
|
+
import trodo
|
|
6
|
+
trodo.init(site_id='your-site-id')
|
|
7
|
+
user = trodo.for_user('user-123')
|
|
8
|
+
user.track('purchase_completed', {'amount': 99.99})
|
|
9
|
+
|
|
10
|
+
Usage (class):
|
|
11
|
+
from trodo import TrodoClient
|
|
12
|
+
client = TrodoClient(site_id='your-site-id')
|
|
13
|
+
user = client.for_user('user-123')
|
|
14
|
+
user.track('purchase_completed', {'amount': 99.99})
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Dict, List, Optional, Union
|
|
20
|
+
|
|
21
|
+
from .client import TrodoClient
|
|
22
|
+
from .user_context import UserContext
|
|
23
|
+
from .managers.group_manager import GroupProfile
|
|
24
|
+
from .types import ApiResult, IdentifyResult, ResetResult, WalletAddressResult
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"TrodoClient",
|
|
28
|
+
"UserContext",
|
|
29
|
+
"GroupProfile",
|
|
30
|
+
"init",
|
|
31
|
+
"for_user",
|
|
32
|
+
"track",
|
|
33
|
+
"identify",
|
|
34
|
+
"wallet_address",
|
|
35
|
+
"reset",
|
|
36
|
+
"enable_auto_events",
|
|
37
|
+
"disable_auto_events",
|
|
38
|
+
"flush",
|
|
39
|
+
"shutdown",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# ============================================================================
|
|
43
|
+
# Singleton convenience API
|
|
44
|
+
# ============================================================================
|
|
45
|
+
|
|
46
|
+
_client: Optional[TrodoClient] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_client() -> TrodoClient:
|
|
50
|
+
if _client is None:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
"trodo-python: Call trodo.init(site_id=...) before using the SDK."
|
|
53
|
+
)
|
|
54
|
+
return _client
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def init(
|
|
58
|
+
site_id: str,
|
|
59
|
+
api_base: str = "https://sdkapi.trodo.ai",
|
|
60
|
+
timeout: int = 10,
|
|
61
|
+
retries: int = 2,
|
|
62
|
+
batch_enabled: bool = False,
|
|
63
|
+
batch_size: int = 50,
|
|
64
|
+
batch_flush_interval: float = 5.0,
|
|
65
|
+
auto_events: bool = False,
|
|
66
|
+
on_error: Optional[Any] = None,
|
|
67
|
+
debug: bool = False,
|
|
68
|
+
) -> TrodoClient:
|
|
69
|
+
"""Initialise the singleton SDK instance."""
|
|
70
|
+
global _client
|
|
71
|
+
_client = TrodoClient(
|
|
72
|
+
site_id=site_id,
|
|
73
|
+
api_base=api_base,
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
retries=retries,
|
|
76
|
+
batch_enabled=batch_enabled,
|
|
77
|
+
batch_size=batch_size,
|
|
78
|
+
batch_flush_interval=batch_flush_interval,
|
|
79
|
+
auto_events=auto_events,
|
|
80
|
+
on_error=on_error,
|
|
81
|
+
debug=debug,
|
|
82
|
+
)
|
|
83
|
+
return _client
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def for_user(
|
|
87
|
+
distinct_id: str,
|
|
88
|
+
session_id: Optional[str] = None,
|
|
89
|
+
) -> UserContext:
|
|
90
|
+
"""Return a UserContext bound to the given distinctId."""
|
|
91
|
+
return _get_client().for_user(distinct_id, session_id)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def track(
|
|
95
|
+
distinct_id: str,
|
|
96
|
+
event_name: str,
|
|
97
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
98
|
+
category: str = "custom",
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Track an event for a user (direct-call pattern)."""
|
|
101
|
+
_get_client().track(distinct_id, event_name, properties, category)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def identify(distinct_id: str, identify_id: str) -> IdentifyResult:
|
|
105
|
+
"""Alias a user's distinctId to an external identifier."""
|
|
106
|
+
return _get_client().identify(distinct_id, identify_id)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def wallet_address(distinct_id: str, wallet_addr: str) -> WalletAddressResult:
|
|
110
|
+
"""Associate a wallet address with a user."""
|
|
111
|
+
return _get_client().wallet_address(distinct_id, wallet_addr)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def reset(distinct_id: str) -> ResetResult:
|
|
115
|
+
"""Reset a user's session."""
|
|
116
|
+
return _get_client().reset(distinct_id)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def enable_auto_events() -> None:
|
|
120
|
+
_get_client().enable_auto_events()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def disable_auto_events() -> None:
|
|
124
|
+
_get_client().disable_auto_events()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def flush() -> None:
|
|
128
|
+
"""Flush any queued batch events."""
|
|
129
|
+
_get_client().flush()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def shutdown() -> None:
|
|
133
|
+
"""Flush, stop timers, and disable auto events."""
|
|
134
|
+
_get_client().shutdown()
|
trodo/api/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Async HTTP client using httpx (optional dependency)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Any, Callable, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from ..types import ApiResult, EventPayload
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncHttpClient:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
api_base: str,
|
|
16
|
+
site_id: str,
|
|
17
|
+
timeout: int = 10,
|
|
18
|
+
retries: int = 2,
|
|
19
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
20
|
+
debug: bool = False,
|
|
21
|
+
) -> None:
|
|
22
|
+
try:
|
|
23
|
+
import httpx # noqa: F401
|
|
24
|
+
except ImportError:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"trodo-python async support requires httpx: pip install trodo-python[async]"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
self.api_base = api_base.rstrip("/")
|
|
30
|
+
self.site_id = site_id
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
self.retries = retries
|
|
33
|
+
self.on_error = on_error
|
|
34
|
+
self.debug = debug
|
|
35
|
+
|
|
36
|
+
def _log(self, *args: Any) -> None:
|
|
37
|
+
if self.debug:
|
|
38
|
+
sys.stderr.write(f"[trodo-python-async] {' '.join(str(a) for a in args)}\n")
|
|
39
|
+
|
|
40
|
+
async def _request(
|
|
41
|
+
self, path: str, body: Dict[str, Any], attempt: int = 0
|
|
42
|
+
) -> ApiResult:
|
|
43
|
+
import httpx
|
|
44
|
+
|
|
45
|
+
url = f"{self.api_base}{path}"
|
|
46
|
+
self._log(f"POST {url}")
|
|
47
|
+
headers = {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"X-Trodo-Site-Id": self.site_id,
|
|
50
|
+
}
|
|
51
|
+
try:
|
|
52
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
53
|
+
resp = await client.post(url, json=body, headers=headers)
|
|
54
|
+
if resp.status_code >= 500 and attempt < self.retries:
|
|
55
|
+
delay = 2 ** attempt
|
|
56
|
+
self._log(f"Retry {attempt + 1} after {delay}s")
|
|
57
|
+
await asyncio.sleep(delay)
|
|
58
|
+
return await self._request(path, body, attempt + 1)
|
|
59
|
+
try:
|
|
60
|
+
return resp.json()
|
|
61
|
+
except Exception:
|
|
62
|
+
return {}
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
if attempt < self.retries:
|
|
65
|
+
await asyncio.sleep(2 ** attempt)
|
|
66
|
+
return await self._request(path, body, attempt + 1)
|
|
67
|
+
self._log(f"Error: {exc}")
|
|
68
|
+
if self.on_error:
|
|
69
|
+
self.on_error(exc)
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
async def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
|
|
73
|
+
return await self._request("/api/sdk/track", {"sessionData": session_data})
|
|
74
|
+
|
|
75
|
+
async def post_event(self, event: EventPayload) -> ApiResult:
|
|
76
|
+
return await self._request("/api/events", event.to_dict())
|
|
77
|
+
|
|
78
|
+
async def post_bulk_events(self, events: list) -> ApiResult:
|
|
79
|
+
return await self._request(
|
|
80
|
+
"/api/events/bulk", {"events": [e.to_dict() for e in events]}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def post_identify(self, payload: Dict[str, Any]) -> ApiResult:
|
|
84
|
+
return await self._request("/api/sdk/identify", payload)
|
|
85
|
+
|
|
86
|
+
async def post_wallet_address(self, payload: Dict[str, Any]) -> ApiResult:
|
|
87
|
+
return await self._request("/api/sdk/wallet-address", payload)
|
|
88
|
+
|
|
89
|
+
async def post_reset(self, payload: Dict[str, Any]) -> ApiResult:
|
|
90
|
+
return await self._request("/api/sdk/reset", payload)
|
|
91
|
+
|
|
92
|
+
async def post_people(self, path: str, payload: Dict[str, Any]) -> ApiResult:
|
|
93
|
+
return await self._request(path, payload)
|
|
94
|
+
|
|
95
|
+
async def post_group(self, path: str, payload: Dict[str, Any]) -> ApiResult:
|
|
96
|
+
return await self._request(path, payload)
|
trodo/api/endpoints.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
TRACK = "/api/sdk/track"
|
|
2
|
+
EVENTS = "/api/events"
|
|
3
|
+
EVENTS_BULK = "/api/events/bulk"
|
|
4
|
+
IDENTIFY = "/api/sdk/identify"
|
|
5
|
+
WALLET_ADDRESS = "/api/sdk/wallet-address"
|
|
6
|
+
RESET = "/api/sdk/reset"
|
|
7
|
+
PEOPLE_SET = "/api/sdk/people/set"
|
|
8
|
+
PEOPLE_SET_ONCE = "/api/sdk/people/set_once"
|
|
9
|
+
PEOPLE_UNSET = "/api/sdk/people/unset"
|
|
10
|
+
PEOPLE_INCREMENT = "/api/sdk/people/increment"
|
|
11
|
+
PEOPLE_APPEND = "/api/sdk/people/append"
|
|
12
|
+
PEOPLE_UNION = "/api/sdk/people/union"
|
|
13
|
+
PEOPLE_REMOVE = "/api/sdk/people/remove"
|
|
14
|
+
PEOPLE_TRACK_CHARGE = "/api/sdk/people/track_charge"
|
|
15
|
+
PEOPLE_CLEAR_CHARGES = "/api/sdk/people/clear_charges"
|
|
16
|
+
PEOPLE_DELETE_USER = "/api/sdk/people/delete_user"
|
|
17
|
+
GROUPS_SET = "/api/sdk/groups/set_group"
|
|
18
|
+
GROUPS_ADD = "/api/sdk/groups/add_group"
|
|
19
|
+
GROUPS_REMOVE = "/api/sdk/groups/remove_group"
|
|
20
|
+
GROUPS_PROFILE = "/api/sdk/groups/profile"
|
trodo/api/http_client.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Synchronous HTTP client using requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Callable, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from ..types import ApiResult, EventPayload
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HttpClient:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
api_base: str,
|
|
18
|
+
site_id: str,
|
|
19
|
+
timeout: int = 10,
|
|
20
|
+
retries: int = 2,
|
|
21
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
22
|
+
debug: bool = False,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.api_base = api_base.rstrip("/")
|
|
25
|
+
self.site_id = site_id
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
self.retries = retries
|
|
28
|
+
self.on_error = on_error
|
|
29
|
+
self.debug = debug
|
|
30
|
+
self._session = requests.Session()
|
|
31
|
+
self._session.headers.update({
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"X-Trodo-Site-Id": self.site_id,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
def _log(self, *args: Any) -> None:
|
|
37
|
+
if self.debug:
|
|
38
|
+
sys.stderr.write(f"[trodo-python] {' '.join(str(a) for a in args)}\n")
|
|
39
|
+
|
|
40
|
+
def _request(self, path: str, body: Dict[str, Any], attempt: int = 0) -> ApiResult:
|
|
41
|
+
url = f"{self.api_base}{path}"
|
|
42
|
+
self._log(f"POST {url}")
|
|
43
|
+
try:
|
|
44
|
+
resp = self._session.post(url, json=body, timeout=self.timeout)
|
|
45
|
+
if resp.status_code >= 500 and attempt < self.retries:
|
|
46
|
+
delay = 2 ** attempt
|
|
47
|
+
self._log(f"Retry {attempt + 1} after {delay}s (status {resp.status_code})")
|
|
48
|
+
time.sleep(delay)
|
|
49
|
+
return self._request(path, body, attempt + 1)
|
|
50
|
+
try:
|
|
51
|
+
return resp.json()
|
|
52
|
+
except Exception:
|
|
53
|
+
return {}
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
if attempt < self.retries:
|
|
56
|
+
delay = 2 ** attempt
|
|
57
|
+
self._log(f"Retry {attempt + 1} after {delay}s (network error)")
|
|
58
|
+
time.sleep(delay)
|
|
59
|
+
return self._request(path, body, attempt + 1)
|
|
60
|
+
self._log(f"Error: {exc}")
|
|
61
|
+
if self.on_error:
|
|
62
|
+
self.on_error(exc)
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
|
|
66
|
+
return self._request("/api/sdk/track", {"sessionData": session_data})
|
|
67
|
+
|
|
68
|
+
def post_event(self, event: EventPayload) -> ApiResult:
|
|
69
|
+
return self._request("/api/events", event.to_dict())
|
|
70
|
+
|
|
71
|
+
def post_bulk_events(self, events: list) -> ApiResult:
|
|
72
|
+
return self._request("/api/events/bulk", {"events": [e.to_dict() for e in events]})
|
|
73
|
+
|
|
74
|
+
def post_identify(self, payload: Dict[str, Any]) -> ApiResult:
|
|
75
|
+
return self._request("/api/sdk/identify", payload)
|
|
76
|
+
|
|
77
|
+
def post_wallet_address(self, payload: Dict[str, Any]) -> ApiResult:
|
|
78
|
+
return self._request("/api/sdk/wallet-address", payload)
|
|
79
|
+
|
|
80
|
+
def post_reset(self, payload: Dict[str, Any]) -> ApiResult:
|
|
81
|
+
return self._request("/api/sdk/reset", payload)
|
|
82
|
+
|
|
83
|
+
def post_people(self, path: str, payload: Dict[str, Any]) -> ApiResult:
|
|
84
|
+
return self._request(path, payload)
|
|
85
|
+
|
|
86
|
+
def post_group(self, path: str, payload: Dict[str, Any]) -> ApiResult:
|
|
87
|
+
return self._request(path, payload)
|
trodo/auto/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Auto event manager — captures unhandled Python exceptions as server_error events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import traceback
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Optional, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from ..types import EventPayload
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..session.session_manager import SessionManager
|
|
15
|
+
from ..api.http_client import HttpClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _now_iso() -> str:
|
|
19
|
+
return datetime.now(timezone.utc).isoformat()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AutoEventManager:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
site_id: str,
|
|
26
|
+
http_client: "HttpClient",
|
|
27
|
+
session_manager: "SessionManager",
|
|
28
|
+
) -> None:
|
|
29
|
+
self._site_id = site_id
|
|
30
|
+
self._http = http_client
|
|
31
|
+
self._session_manager = session_manager
|
|
32
|
+
self._enabled = False
|
|
33
|
+
self._prev_excepthook: Any = None
|
|
34
|
+
self._prev_thread_excepthook: Any = None
|
|
35
|
+
|
|
36
|
+
def enable(self) -> None:
|
|
37
|
+
if self._enabled:
|
|
38
|
+
return
|
|
39
|
+
self._enabled = True
|
|
40
|
+
|
|
41
|
+
# Wrap sys.excepthook
|
|
42
|
+
self._prev_excepthook = sys.excepthook
|
|
43
|
+
|
|
44
|
+
def _excepthook(exc_type, exc_value, exc_tb):
|
|
45
|
+
self._track_error(
|
|
46
|
+
exc_type.__name__ if exc_type else "Exception",
|
|
47
|
+
str(exc_value),
|
|
48
|
+
"".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
|
|
49
|
+
)
|
|
50
|
+
if self._prev_excepthook:
|
|
51
|
+
self._prev_excepthook(exc_type, exc_value, exc_tb)
|
|
52
|
+
|
|
53
|
+
sys.excepthook = _excepthook
|
|
54
|
+
|
|
55
|
+
# Wrap threading.excepthook (Python 3.8+)
|
|
56
|
+
if hasattr(threading, "excepthook"):
|
|
57
|
+
self._prev_thread_excepthook = threading.excepthook
|
|
58
|
+
|
|
59
|
+
def _thread_excepthook(args):
|
|
60
|
+
self._track_error(
|
|
61
|
+
args.exc_type.__name__ if args.exc_type else "ThreadException",
|
|
62
|
+
str(args.exc_value),
|
|
63
|
+
"".join(
|
|
64
|
+
traceback.format_exception(
|
|
65
|
+
args.exc_type, args.exc_value, args.exc_tb
|
|
66
|
+
)
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
if self._prev_thread_excepthook:
|
|
70
|
+
self._prev_thread_excepthook(args)
|
|
71
|
+
|
|
72
|
+
threading.excepthook = _thread_excepthook
|
|
73
|
+
|
|
74
|
+
def disable(self) -> None:
|
|
75
|
+
if not self._enabled:
|
|
76
|
+
return
|
|
77
|
+
self._enabled = False
|
|
78
|
+
|
|
79
|
+
if self._prev_excepthook is not None:
|
|
80
|
+
sys.excepthook = self._prev_excepthook
|
|
81
|
+
self._prev_excepthook = None
|
|
82
|
+
|
|
83
|
+
if hasattr(threading, "excepthook") and self._prev_thread_excepthook is not None:
|
|
84
|
+
threading.excepthook = self._prev_thread_excepthook
|
|
85
|
+
self._prev_thread_excepthook = None
|
|
86
|
+
|
|
87
|
+
def is_enabled(self) -> bool:
|
|
88
|
+
return self._enabled
|
|
89
|
+
|
|
90
|
+
def track_error(
|
|
91
|
+
self,
|
|
92
|
+
error: Exception,
|
|
93
|
+
error_type: Optional[str] = None,
|
|
94
|
+
severity: str = "error",
|
|
95
|
+
distinct_id: str = "server_global",
|
|
96
|
+
) -> None:
|
|
97
|
+
self._track_error(
|
|
98
|
+
error_type or type(error).__name__,
|
|
99
|
+
str(error),
|
|
100
|
+
traceback.format_exc() or repr(error),
|
|
101
|
+
severity=severity,
|
|
102
|
+
distinct_id=distinct_id,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _track_error(
|
|
106
|
+
self,
|
|
107
|
+
error_type: str,
|
|
108
|
+
message: str,
|
|
109
|
+
stack: str,
|
|
110
|
+
severity: str = "critical",
|
|
111
|
+
distinct_id: str = "server_global",
|
|
112
|
+
) -> None:
|
|
113
|
+
try:
|
|
114
|
+
session = self._session_manager.get_or_create(distinct_id, self._site_id)
|
|
115
|
+
self._session_manager.ensure_confirmed(session, self._http)
|
|
116
|
+
|
|
117
|
+
event = EventPayload(
|
|
118
|
+
event_type="auto",
|
|
119
|
+
event_name="server_error",
|
|
120
|
+
event_category="error",
|
|
121
|
+
session_id=session.session_id,
|
|
122
|
+
user_id=session.distinct_id,
|
|
123
|
+
custom_properties={
|
|
124
|
+
"error_type": error_type,
|
|
125
|
+
"message": message,
|
|
126
|
+
"stack": stack,
|
|
127
|
+
"runtime": "python",
|
|
128
|
+
"severity": severity,
|
|
129
|
+
"timestamp": _now_iso(),
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
self._http.post_event(event)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass # Never let error tracking crash the process
|
trodo/client.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""TrodoClient — main SDK class for Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .api.http_client import HttpClient
|
|
8
|
+
from .session.session_manager import SessionManager
|
|
9
|
+
from .managers.people_manager import PeopleManager
|
|
10
|
+
from .managers.group_manager import GroupManager, GroupProfile
|
|
11
|
+
from .queue.event_queue import EventQueue
|
|
12
|
+
from .queue.batch_flusher import BatchFlusher
|
|
13
|
+
from .auto.auto_event_manager import AutoEventManager
|
|
14
|
+
from .user_context import UserContext
|
|
15
|
+
from .types import (
|
|
16
|
+
ApiResult,
|
|
17
|
+
IdentifyResult,
|
|
18
|
+
ResetResult,
|
|
19
|
+
WalletAddressResult,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TrodoClient:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
site_id: str,
|
|
27
|
+
api_base: str = "https://sdkapi.trodo.ai",
|
|
28
|
+
timeout: int = 10,
|
|
29
|
+
retries: int = 2,
|
|
30
|
+
batch_enabled: bool = False,
|
|
31
|
+
batch_size: int = 50,
|
|
32
|
+
batch_flush_interval: float = 5.0,
|
|
33
|
+
auto_events: bool = False,
|
|
34
|
+
on_error: Optional[Any] = None,
|
|
35
|
+
debug: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
if not site_id:
|
|
38
|
+
raise ValueError("trodo-python: site_id is required")
|
|
39
|
+
|
|
40
|
+
self.site_id = site_id
|
|
41
|
+
|
|
42
|
+
self._http = HttpClient(
|
|
43
|
+
api_base=api_base,
|
|
44
|
+
site_id=site_id,
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
retries=retries,
|
|
47
|
+
on_error=on_error,
|
|
48
|
+
debug=debug,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
self._session_manager = SessionManager()
|
|
52
|
+
|
|
53
|
+
if batch_enabled:
|
|
54
|
+
self._event_queue: Optional[EventQueue] = EventQueue(batch_size)
|
|
55
|
+
self._batch_flusher: Optional[BatchFlusher] = BatchFlusher(
|
|
56
|
+
self._event_queue, self._http, batch_flush_interval
|
|
57
|
+
)
|
|
58
|
+
self._batch_flusher.start()
|
|
59
|
+
else:
|
|
60
|
+
self._event_queue = None
|
|
61
|
+
self._batch_flusher = None
|
|
62
|
+
|
|
63
|
+
self._auto_event_manager = AutoEventManager(
|
|
64
|
+
site_id, self._http, self._session_manager
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if auto_events:
|
|
68
|
+
self._auto_event_manager.enable()
|
|
69
|
+
|
|
70
|
+
self._user_context_cache: Dict[str, UserContext] = {}
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
# Primary pattern: for_user()
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def for_user(
|
|
77
|
+
self,
|
|
78
|
+
distinct_id: str,
|
|
79
|
+
session_id: Optional[str] = None,
|
|
80
|
+
) -> UserContext:
|
|
81
|
+
if distinct_id in self._user_context_cache:
|
|
82
|
+
return self._user_context_cache[distinct_id]
|
|
83
|
+
|
|
84
|
+
ctx = UserContext(
|
|
85
|
+
distinct_id=distinct_id,
|
|
86
|
+
site_id=self.site_id,
|
|
87
|
+
http_client=self._http,
|
|
88
|
+
session_manager=self._session_manager,
|
|
89
|
+
event_queue=self._event_queue,
|
|
90
|
+
batch_flusher=self._batch_flusher,
|
|
91
|
+
auto_event_manager=self._auto_event_manager,
|
|
92
|
+
session_id=session_id,
|
|
93
|
+
)
|
|
94
|
+
self._user_context_cache[distinct_id] = ctx
|
|
95
|
+
return ctx
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------------------------------
|
|
98
|
+
# Direct-call pattern (distinct_id as first arg)
|
|
99
|
+
# --------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def track(
|
|
102
|
+
self,
|
|
103
|
+
distinct_id: str,
|
|
104
|
+
event_name: str,
|
|
105
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
106
|
+
category: str = "custom",
|
|
107
|
+
) -> None:
|
|
108
|
+
self.for_user(distinct_id).track(event_name, properties, category)
|
|
109
|
+
|
|
110
|
+
def identify(self, distinct_id: str, identify_id: str) -> IdentifyResult:
|
|
111
|
+
return self.for_user(distinct_id).identify(identify_id)
|
|
112
|
+
|
|
113
|
+
def wallet_address(self, distinct_id: str, wallet_addr: str) -> WalletAddressResult:
|
|
114
|
+
return self.for_user(distinct_id).wallet_address(wallet_addr)
|
|
115
|
+
|
|
116
|
+
def reset(self, distinct_id: str) -> ResetResult:
|
|
117
|
+
self._user_context_cache.pop(distinct_id, None)
|
|
118
|
+
return self.for_user(distinct_id).reset()
|
|
119
|
+
|
|
120
|
+
# People (direct)
|
|
121
|
+
def people_set(self, distinct_id: str, properties: Dict[str, Any]) -> ApiResult:
|
|
122
|
+
return self.for_user(distinct_id).people.set(properties)
|
|
123
|
+
|
|
124
|
+
def people_set_once(self, distinct_id: str, properties: Dict[str, Any]) -> ApiResult:
|
|
125
|
+
return self.for_user(distinct_id).people.set_once(properties)
|
|
126
|
+
|
|
127
|
+
def people_unset(self, distinct_id: str, keys: Union[str, List[str]]) -> ApiResult:
|
|
128
|
+
return self.for_user(distinct_id).people.unset(keys)
|
|
129
|
+
|
|
130
|
+
def people_increment(self, distinct_id: str, key: str, amount: float = 1) -> ApiResult:
|
|
131
|
+
return self.for_user(distinct_id).people.increment(key, amount)
|
|
132
|
+
|
|
133
|
+
def people_append(self, distinct_id: str, key: str, values: List[Any]) -> ApiResult:
|
|
134
|
+
return self.for_user(distinct_id).people.append(key, values)
|
|
135
|
+
|
|
136
|
+
def people_union(self, distinct_id: str, key: str, values: List[Any]) -> ApiResult:
|
|
137
|
+
return self.for_user(distinct_id).people.union(key, values)
|
|
138
|
+
|
|
139
|
+
def people_remove(self, distinct_id: str, key: str, values: List[Any]) -> ApiResult:
|
|
140
|
+
return self.for_user(distinct_id).people.remove(key, values)
|
|
141
|
+
|
|
142
|
+
def people_track_charge(
|
|
143
|
+
self,
|
|
144
|
+
distinct_id: str,
|
|
145
|
+
amount: float,
|
|
146
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
147
|
+
) -> ApiResult:
|
|
148
|
+
return self.for_user(distinct_id).people.track_charge(amount, properties)
|
|
149
|
+
|
|
150
|
+
def people_clear_charges(self, distinct_id: str) -> ApiResult:
|
|
151
|
+
return self.for_user(distinct_id).people.clear_charges()
|
|
152
|
+
|
|
153
|
+
def people_delete_user(self, distinct_id: str) -> ApiResult:
|
|
154
|
+
return self.for_user(distinct_id).people.delete_user()
|
|
155
|
+
|
|
156
|
+
# Groups (direct)
|
|
157
|
+
def set_group(
|
|
158
|
+
self, distinct_id: str, group_key: str, group_id: Union[str, List[str]]
|
|
159
|
+
) -> ApiResult:
|
|
160
|
+
return self.for_user(distinct_id).set_group(group_key, group_id)
|
|
161
|
+
|
|
162
|
+
def add_group(self, distinct_id: str, group_key: str, group_id: str) -> ApiResult:
|
|
163
|
+
return self.for_user(distinct_id).add_group(group_key, group_id)
|
|
164
|
+
|
|
165
|
+
def remove_group(self, distinct_id: str, group_key: str, group_id: str) -> ApiResult:
|
|
166
|
+
return self.for_user(distinct_id).remove_group(group_key, group_id)
|
|
167
|
+
|
|
168
|
+
def get_group(
|
|
169
|
+
self, distinct_id: str, group_key: str, group_id: str
|
|
170
|
+
) -> GroupProfile:
|
|
171
|
+
return self.for_user(distinct_id).get_group(group_key, group_id)
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------
|
|
174
|
+
# Auto events
|
|
175
|
+
# --------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def enable_auto_events(self) -> None:
|
|
178
|
+
self._auto_event_manager.enable()
|
|
179
|
+
|
|
180
|
+
def disable_auto_events(self) -> None:
|
|
181
|
+
self._auto_event_manager.disable()
|
|
182
|
+
|
|
183
|
+
# --------------------------------------------------------------------------
|
|
184
|
+
# Lifecycle
|
|
185
|
+
# --------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def flush(self) -> None:
|
|
188
|
+
if self._batch_flusher:
|
|
189
|
+
self._batch_flusher.flush()
|
|
190
|
+
|
|
191
|
+
def shutdown(self) -> None:
|
|
192
|
+
self._auto_event_manager.disable()
|
|
193
|
+
if self._batch_flusher:
|
|
194
|
+
self._batch_flusher.stop()
|
|
195
|
+
self._batch_flusher.flush()
|
|
File without changes
|