tapcub 0.1.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.
- tapcub/__init__.py +12 -0
- tapcub/_normalize.py +254 -0
- tapcub/client.py +394 -0
- tapcub/py.typed +0 -0
- tapcub/version.py +2 -0
- tapcub-0.1.0.dist-info/METADATA +164 -0
- tapcub-0.1.0.dist-info/RECORD +10 -0
- tapcub-0.1.0.dist-info/WHEEL +5 -0
- tapcub-0.1.0.dist-info/licenses/LICENSE +21 -0
- tapcub-0.1.0.dist-info/top_level.txt +1 -0
tapcub/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""TapCub server-side SDK for Python: events, sign-ups, identities, user profiles, items and accounts.
|
|
2
|
+
|
|
3
|
+
from tapcub import TapCub
|
|
4
|
+
tc = TapCub("wck_…")
|
|
5
|
+
tc.track("purchase", login_id="user-1", insert_id="order-1", properties={"revenue": 19.9})
|
|
6
|
+
tc.close()
|
|
7
|
+
"""
|
|
8
|
+
from .client import DEFAULT_ENDPOINT, TapCub, TapCubError, from_request_headers, normalize_endpoint
|
|
9
|
+
from .version import VERSION
|
|
10
|
+
|
|
11
|
+
__version__ = VERSION
|
|
12
|
+
__all__ = ["TapCub", "TapCubError", "from_request_headers", "normalize_endpoint", "DEFAULT_ENDPOINT", "VERSION", "__version__"]
|
tapcub/_normalize.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Event normalization shared by every TapCub server SDK.
|
|
2
|
+
|
|
3
|
+
Rules: packages/shared/test-vectors/track/README.md (same limits as the server's request schema).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import datetime as _dt
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import time as _time
|
|
13
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
14
|
+
|
|
15
|
+
LOGIN_ID_TYPES = ("login", "email", "mobile", "unionid", "openid", "custom")
|
|
16
|
+
PLATFORMS = (
|
|
17
|
+
"web", "mp_weixin", "mp_alipay", "mp_bytedance", "mp_baidu", "mp_kuaishou", "mp_qq",
|
|
18
|
+
"android", "ios", "harmony", "tvos", "watchos", "macos", "windows", "linux", "server", "import",
|
|
19
|
+
)
|
|
20
|
+
_GROUP_KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,23}:[A-Za-z0-9_@.\-]{1,80}$")
|
|
21
|
+
_GROUP_TYPE_RE = re.compile(r"^[a-z][a-z0-9_]{0,23}$")
|
|
22
|
+
_HEX64 = re.compile(r"^[a-fA-F0-9]{64}$")
|
|
23
|
+
_COUNTRY = re.compile(r"^[A-Za-z]{2}$")
|
|
24
|
+
_CAMPAIGN_LIMITS = (
|
|
25
|
+
("source", 100), ("medium", 100), ("name", 100), ("term", 100), ("content", 100),
|
|
26
|
+
("ref", 64), ("click_id", 1000), ("landing_url", 2000), ("referrer", 2000),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sha256(s: str) -> str:
|
|
31
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def utf16_len(s: str) -> int:
|
|
35
|
+
"""服务端 zod 的 max 按 JS .length(UTF-16 码元)计"""
|
|
36
|
+
return len(s) + sum(1 for ch in s if ord(ch) > 0xFFFF)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cut(s: str, n: int) -> str:
|
|
40
|
+
"""截到 ≤n 个 UTF-16 码元,不拆开代理对"""
|
|
41
|
+
if len(s) <= n // 2 or utf16_len(s) <= n:
|
|
42
|
+
return s
|
|
43
|
+
units = 0
|
|
44
|
+
for i, ch in enumerate(s):
|
|
45
|
+
units += 2 if ord(ch) > 0xFFFF else 1
|
|
46
|
+
if units > n:
|
|
47
|
+
return s[:i]
|
|
48
|
+
return s
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _text(v: Any) -> str:
|
|
52
|
+
return v if isinstance(v, str) else str(v)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_text(name: str, v: Any, max_len: Optional[int] = None) -> str:
|
|
56
|
+
s = _text(v).strip() if v is not None else ""
|
|
57
|
+
if not s:
|
|
58
|
+
raise ValueError(f"TapCub: {name} is required")
|
|
59
|
+
if max_len is not None and utf16_len(s) > max_len:
|
|
60
|
+
raise ValueError(f"TapCub: {name} must be at most {max_len} characters")
|
|
61
|
+
return s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def optional_text(name: str, v: Any, max_len: int) -> Optional[str]:
|
|
65
|
+
if v is None:
|
|
66
|
+
return None
|
|
67
|
+
s = _text(v).strip()
|
|
68
|
+
if not s:
|
|
69
|
+
return None
|
|
70
|
+
if utf16_len(s) > max_len:
|
|
71
|
+
raise ValueError(f"TapCub: {name} must be at most {max_len} characters")
|
|
72
|
+
return s
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def event_name(v: Any) -> str:
|
|
76
|
+
s = _text(v).strip() if v is not None else ""
|
|
77
|
+
if not s:
|
|
78
|
+
raise ValueError("TapCub: event is required")
|
|
79
|
+
return cut(s, 100)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def login_id_type(v: Any) -> Optional[str]:
|
|
83
|
+
if v is None:
|
|
84
|
+
return None
|
|
85
|
+
if v not in LOGIN_ID_TYPES:
|
|
86
|
+
raise ValueError(f"TapCub: login_id_type must be one of {', '.join(LOGIN_ID_TYPES)}")
|
|
87
|
+
return v
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def identity_value(kind: str, value: Any) -> str:
|
|
91
|
+
v = _text(value).strip() if value is not None else ""
|
|
92
|
+
if kind in ("email", "mobile"):
|
|
93
|
+
n = v.lower() if kind == "email" else re.sub(r"[\s-]", "", v)
|
|
94
|
+
return n.lower() if _HEX64.match(n) else sha256(n)
|
|
95
|
+
return v
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def identities(v: Optional[Mapping[str, Any]]) -> Dict[str, str]:
|
|
99
|
+
out: Dict[str, str] = {}
|
|
100
|
+
for k, raw in (v or {}).items():
|
|
101
|
+
if k not in LOGIN_ID_TYPES:
|
|
102
|
+
raise ValueError(f'TapCub: unknown identity type "{k}"')
|
|
103
|
+
if raw is None or _text(raw).strip() == "":
|
|
104
|
+
continue
|
|
105
|
+
out[k] = identity_value(k, raw)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def group(v: Any) -> Optional[str]:
|
|
110
|
+
if v is None:
|
|
111
|
+
return None
|
|
112
|
+
s = _text(v)
|
|
113
|
+
if not _GROUP_KEY_RE.match(s):
|
|
114
|
+
raise ValueError('TapCub: group must look like "type:id" (type: a-z, 0-9, _)')
|
|
115
|
+
return s
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def group_key(kind: Any, gid: Any) -> str:
|
|
119
|
+
t = require_text("group_type", kind)
|
|
120
|
+
if not _GROUP_TYPE_RE.match(t):
|
|
121
|
+
raise ValueError("TapCub: group_type must start with a-z and contain only a-z, 0-9 and _ (at most 24)")
|
|
122
|
+
return group(f"{t}:{require_text('group_id', gid)}") # type: ignore[return-value]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def timestamp(v: Any) -> int:
|
|
126
|
+
"""毫秒时间戳;datetime 按自身时区(naive 视为本地时间,同 datetime.timestamp())"""
|
|
127
|
+
if v is None:
|
|
128
|
+
return int(_time.time() * 1000)
|
|
129
|
+
if isinstance(v, _dt.datetime):
|
|
130
|
+
return int(v.timestamp() * 1000)
|
|
131
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or (isinstance(v, float) and not math.isfinite(v)):
|
|
132
|
+
raise ValueError("TapCub: time must be milliseconds since the epoch or a datetime")
|
|
133
|
+
return int(v)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _json_text(v: Any) -> str:
|
|
137
|
+
return json.dumps(v, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _json_default(o: Any) -> Any:
|
|
141
|
+
if isinstance(o, (_dt.datetime, _dt.date)):
|
|
142
|
+
return o.isoformat()
|
|
143
|
+
if isinstance(o, (set, frozenset, tuple)):
|
|
144
|
+
return list(o)
|
|
145
|
+
return str(o)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _scalar(v: Any) -> Any:
|
|
149
|
+
"""返回 None 表示丢弃"""
|
|
150
|
+
if v is None:
|
|
151
|
+
return None
|
|
152
|
+
if isinstance(v, bool):
|
|
153
|
+
return v
|
|
154
|
+
if isinstance(v, int):
|
|
155
|
+
return v
|
|
156
|
+
if isinstance(v, float):
|
|
157
|
+
return v if math.isfinite(v) else None
|
|
158
|
+
if isinstance(v, str):
|
|
159
|
+
return cut(v, 1000)
|
|
160
|
+
if isinstance(v, (_dt.datetime, _dt.date)):
|
|
161
|
+
return v.isoformat()
|
|
162
|
+
if isinstance(v, (Mapping, list, tuple, set, frozenset)):
|
|
163
|
+
return cut(_json_text(v), 1000)
|
|
164
|
+
return cut(str(v), 1000)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def properties(p: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
168
|
+
if not p:
|
|
169
|
+
return None
|
|
170
|
+
out: Dict[str, Any] = {}
|
|
171
|
+
for k, v in p.items():
|
|
172
|
+
key = cut(_text(k), 50)
|
|
173
|
+
if isinstance(v, (list, tuple)) and v and all(isinstance(x, Mapping) for x in v):
|
|
174
|
+
rows = []
|
|
175
|
+
for o in list(v)[:100]:
|
|
176
|
+
row = {}
|
|
177
|
+
for ik, iv in o.items():
|
|
178
|
+
s = _scalar(iv)
|
|
179
|
+
if s is not None:
|
|
180
|
+
row[cut(_text(ik), 50)] = s
|
|
181
|
+
rows.append(row)
|
|
182
|
+
out[key] = rows
|
|
183
|
+
continue
|
|
184
|
+
s = _scalar(v)
|
|
185
|
+
if s is not None:
|
|
186
|
+
out[key] = s
|
|
187
|
+
return out or None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def increment(p: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
191
|
+
out: Dict[str, Any] = {}
|
|
192
|
+
for k, v in (p or {}).items():
|
|
193
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
194
|
+
continue
|
|
195
|
+
if isinstance(v, float) and not math.isfinite(v):
|
|
196
|
+
continue
|
|
197
|
+
out[cut(_text(k), 50)] = v
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def append(p: Optional[Mapping[str, Any]]) -> Dict[str, List[Any]]:
|
|
202
|
+
out: Dict[str, List[Any]] = {}
|
|
203
|
+
for k, v in (p or {}).items():
|
|
204
|
+
items = list(v) if isinstance(v, (list, tuple)) else [v]
|
|
205
|
+
keep = [x for x in items if isinstance(x, (str, bool)) or (isinstance(x, (int, float)) and math.isfinite(x))]
|
|
206
|
+
if keep:
|
|
207
|
+
out[cut(_text(k), 50)] = keep[:20]
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def unset_keys(keys: Any) -> List[str]:
|
|
212
|
+
if isinstance(keys, (str, bytes)) or not isinstance(keys, (list, tuple)):
|
|
213
|
+
raise ValueError("TapCub: keys must be a list of property names")
|
|
214
|
+
return [cut(_text(k), 50) for k in keys if k is not None and _text(k) != ""][:50]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def campaign(c: Any) -> Optional[Dict[str, Any]]:
|
|
218
|
+
if not isinstance(c, Mapping):
|
|
219
|
+
return None
|
|
220
|
+
out: Dict[str, Any] = {}
|
|
221
|
+
for k, lim in _CAMPAIGN_LIMITS:
|
|
222
|
+
v = c.get(k)
|
|
223
|
+
if v is not None and _text(v) != "":
|
|
224
|
+
out[k] = cut(_text(v), lim)
|
|
225
|
+
t = c.get("touched_at")
|
|
226
|
+
if not isinstance(t, bool) and isinstance(t, (int, float)) and math.isfinite(t):
|
|
227
|
+
out["touched_at"] = int(t)
|
|
228
|
+
return out or None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def context(c: Any) -> Optional[Dict[str, Any]]:
|
|
232
|
+
if not isinstance(c, Mapping):
|
|
233
|
+
return None
|
|
234
|
+
out: Dict[str, Any] = {}
|
|
235
|
+
for k, lim in (("ip", 45), ("user_agent", 1000), ("url", 2000)):
|
|
236
|
+
v = c.get(k)
|
|
237
|
+
if v is not None and _text(v) != "":
|
|
238
|
+
out[k] = cut(_text(v), lim)
|
|
239
|
+
if c.get("platform") in PLATFORMS:
|
|
240
|
+
out["platform"] = c["platform"]
|
|
241
|
+
country = c.get("country")
|
|
242
|
+
if isinstance(country, str) and _COUNTRY.match(country):
|
|
243
|
+
out["country"] = country.upper()
|
|
244
|
+
lang = c.get("lang")
|
|
245
|
+
if lang is not None and _text(lang) != "":
|
|
246
|
+
out["lang"] = cut(_text(lang), 12)
|
|
247
|
+
camp = campaign(c.get("campaign"))
|
|
248
|
+
if camp:
|
|
249
|
+
out["campaign"] = camp
|
|
250
|
+
return out or None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def compact(ev: Mapping[str, Any]) -> Dict[str, Any]:
|
|
254
|
+
return {k: v for k, v in ev.items() if v is not None}
|
tapcub/client.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import gzip as _gzip
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
import weakref
|
|
14
|
+
from collections import deque
|
|
15
|
+
from typing import Any, Callable, Deque, Dict, List, Mapping, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from . import _normalize as n
|
|
18
|
+
from .version import VERSION
|
|
19
|
+
|
|
20
|
+
DEFAULT_ENDPOINT = "https://api.pathclue.com"
|
|
21
|
+
MAX_BODY_BYTES = 1_000_000
|
|
22
|
+
_log = logging.getLogger("tapcub")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TapCubError(Exception):
|
|
26
|
+
"""A batch that could not be delivered, or events the server rejected.
|
|
27
|
+
|
|
28
|
+
code: server code (``NO_API_KEY``, ``BAD_API_KEY``, ``VALIDATION`` …), ``HTTP_<status>``, ``NETWORK``,
|
|
29
|
+
``EVENT_REJECTED`` or ``QUEUE_FULL``. ``events`` are the events that were not stored.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, code: str, status: Optional[int] = None, events: Optional[List[dict]] = None, results: Optional[List[dict]] = None):
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.code = code
|
|
35
|
+
self.status = status
|
|
36
|
+
self.events = events or []
|
|
37
|
+
self.results = results
|
|
38
|
+
self.retry_after: Optional[float] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_endpoint(endpoint: Optional[str]) -> str:
|
|
42
|
+
e = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/")
|
|
43
|
+
e = re.sub(r"/api/v1(/track)?$", "", e)
|
|
44
|
+
if not re.match(r"^https?://", e):
|
|
45
|
+
raise ValueError("TapCub: endpoint must start with http:// or https://")
|
|
46
|
+
return e
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def from_request_headers(headers: Mapping[str, Any]) -> Dict[str, Optional[str]]:
|
|
50
|
+
"""Read the device and session IDs a TapCub client put on a request to your backend
|
|
51
|
+
(``X-Tc-Device`` / ``X-Tc-Session``). Works with Django / Flask / Starlette ``request.headers``,
|
|
52
|
+
a WSGI ``environ`` or a plain dict. Returns ``{"device_id": …, "session_id": …}``."""
|
|
53
|
+
|
|
54
|
+
def get(name: str) -> Optional[str]:
|
|
55
|
+
v = None
|
|
56
|
+
if hasattr(headers, "get"):
|
|
57
|
+
v = headers.get(name) or headers.get(name.lower()) or headers.get("HTTP_" + name.upper().replace("-", "_"))
|
|
58
|
+
if isinstance(v, (list, tuple)):
|
|
59
|
+
v = v[0] if v else None
|
|
60
|
+
return v.strip() or None if isinstance(v, str) else None
|
|
61
|
+
|
|
62
|
+
return {"device_id": get("X-Tc-Device"), "session_id": get("X-Tc-Session")}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TapCub:
|
|
66
|
+
"""TapCub server-side client.
|
|
67
|
+
|
|
68
|
+
consumer:
|
|
69
|
+
``"batch"`` (default) queue in memory and send from a background thread every ``flush_interval`` seconds or
|
|
70
|
+
when ``max_batch`` events are queued; flushed again when the interpreter exits.
|
|
71
|
+
``"sync"`` send on every call and raise ``TapCubError`` on failure.
|
|
72
|
+
``"debug"`` like ``sync``, against ``?debug=1`` (validated, nothing stored), every result is logged.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_key: str,
|
|
78
|
+
endpoint: Optional[str] = None,
|
|
79
|
+
*,
|
|
80
|
+
consumer: str = "batch",
|
|
81
|
+
max_batch: int = 200,
|
|
82
|
+
flush_interval: float = 3.0,
|
|
83
|
+
max_queue_size: int = 10_000,
|
|
84
|
+
max_retries: int = 5,
|
|
85
|
+
timeout: float = 10.0,
|
|
86
|
+
gzip: bool = False,
|
|
87
|
+
on_error: Optional[Callable[[TapCubError], None]] = None,
|
|
88
|
+
logger: Optional[logging.Logger] = None,
|
|
89
|
+
_retry_base: float = 0.5,
|
|
90
|
+
):
|
|
91
|
+
if not api_key or not str(api_key).strip():
|
|
92
|
+
raise ValueError("TapCub: api_key is required")
|
|
93
|
+
if consumer not in ("batch", "sync", "debug"):
|
|
94
|
+
raise ValueError("TapCub: consumer must be batch, sync or debug")
|
|
95
|
+
self.api_key = str(api_key).strip()
|
|
96
|
+
self.endpoint = normalize_endpoint(endpoint)
|
|
97
|
+
self.consumer = consumer
|
|
98
|
+
self.max_batch = max(1, min(1000, int(max_batch)))
|
|
99
|
+
self.max_queue_size = max(1, int(max_queue_size))
|
|
100
|
+
self.max_retries = max(0, int(max_retries))
|
|
101
|
+
self.timeout = timeout
|
|
102
|
+
self.gzip = bool(gzip)
|
|
103
|
+
self.logger = logger or _log
|
|
104
|
+
self.on_error = on_error or (lambda e: self.logger.error("[tapcub] %s", e))
|
|
105
|
+
self._retry_base = _retry_base
|
|
106
|
+
self._queue: Deque[Tuple[dict, int]] = deque()
|
|
107
|
+
self._dropped = 0
|
|
108
|
+
self._lock = threading.Lock()
|
|
109
|
+
self._send_lock = threading.Lock()
|
|
110
|
+
self._wake = threading.Event()
|
|
111
|
+
self._stop = threading.Event()
|
|
112
|
+
self._thread: Optional[threading.Thread] = None
|
|
113
|
+
self._flush_interval = max(0.01, float(flush_interval))
|
|
114
|
+
if consumer == "batch":
|
|
115
|
+
self._thread = threading.Thread(target=self._run, name="tapcub-sender", daemon=True)
|
|
116
|
+
self._thread.start()
|
|
117
|
+
ref = weakref.ref(self)
|
|
118
|
+
self._atexit = lambda: ref() and ref()._shutdown() # type: ignore[union-attr]
|
|
119
|
+
atexit.register(self._atexit)
|
|
120
|
+
|
|
121
|
+
# ---------------- events ----------------
|
|
122
|
+
|
|
123
|
+
def track(
|
|
124
|
+
self,
|
|
125
|
+
event: str,
|
|
126
|
+
*,
|
|
127
|
+
login_id: Optional[str] = None,
|
|
128
|
+
login_id_type: Optional[str] = None,
|
|
129
|
+
device_id: Optional[str] = None,
|
|
130
|
+
session_id: Optional[str] = None,
|
|
131
|
+
insert_id: Optional[str] = None,
|
|
132
|
+
time: Any = None,
|
|
133
|
+
group: Optional[str] = None,
|
|
134
|
+
properties: Optional[Mapping[str, Any]] = None,
|
|
135
|
+
context: Optional[Mapping[str, Any]] = None,
|
|
136
|
+
) -> Optional[dict]:
|
|
137
|
+
"""A business event. ``insert_id`` (≤36) is the idempotency key; it is not generated for you."""
|
|
138
|
+
kind = n.login_id_type(login_id_type)
|
|
139
|
+
raw = n.optional_text("login_id", login_id, 200)
|
|
140
|
+
return self._enqueue({
|
|
141
|
+
"type": "track",
|
|
142
|
+
"event": n.event_name(event),
|
|
143
|
+
"login_id": n.identity_value(kind, raw) if raw and kind in ("email", "mobile") else raw,
|
|
144
|
+
"login_id_type": kind,
|
|
145
|
+
"device_id": n.optional_text("device_id", device_id, 72),
|
|
146
|
+
"session_id": n.optional_text("session_id", session_id, 32),
|
|
147
|
+
"insert_id": n.optional_text("insert_id", insert_id, 36),
|
|
148
|
+
"time": n.timestamp(time),
|
|
149
|
+
"group": n.group(group),
|
|
150
|
+
"properties": n.properties(properties),
|
|
151
|
+
"context": n.context(context),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
def track_signup(
|
|
155
|
+
self,
|
|
156
|
+
login_id: str,
|
|
157
|
+
*,
|
|
158
|
+
device_id: Optional[str] = None,
|
|
159
|
+
session_id: Optional[str] = None,
|
|
160
|
+
insert_id: Optional[str] = None,
|
|
161
|
+
time: Any = None,
|
|
162
|
+
properties: Optional[Mapping[str, Any]] = None,
|
|
163
|
+
invited_by: Optional[str] = None,
|
|
164
|
+
context: Optional[Mapping[str, Any]] = None,
|
|
165
|
+
) -> Optional[dict]:
|
|
166
|
+
"""Sign-up: links the anonymous device (if any) to the new login ID and records ``sign_up``.
|
|
167
|
+
``invited_by`` is the inviter's login ID (sent as ``properties.$invited_by``)."""
|
|
168
|
+
props = dict(properties or {})
|
|
169
|
+
if invited_by is not None and str(invited_by).strip():
|
|
170
|
+
props["$invited_by"] = str(invited_by).strip()
|
|
171
|
+
return self._enqueue({
|
|
172
|
+
"type": "track_signup",
|
|
173
|
+
"event": "sign_up",
|
|
174
|
+
"login_id": n.require_text("login_id", login_id, 200),
|
|
175
|
+
"device_id": n.optional_text("device_id", device_id, 72),
|
|
176
|
+
"session_id": n.optional_text("session_id", session_id, 32),
|
|
177
|
+
"insert_id": n.optional_text("insert_id", insert_id, 36),
|
|
178
|
+
"time": n.timestamp(time),
|
|
179
|
+
"properties": n.properties(props),
|
|
180
|
+
"context": n.context(context),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
def track_update(self, event: str, insert_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
|
|
184
|
+
"""Merge properties into the stored event with this ``insert_id``."""
|
|
185
|
+
return self._enqueue({"type": "track_update", "event": n.event_name(event), "insert_id": n.require_text("insert_id", insert_id, 36), "time": n.timestamp(None), "properties": n.properties(properties)})
|
|
186
|
+
|
|
187
|
+
def track_overwrite(self, event: str, insert_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
|
|
188
|
+
"""Replace the properties of the stored event with this ``insert_id``."""
|
|
189
|
+
return self._enqueue({"type": "track_overwrite", "event": n.event_name(event), "insert_id": n.require_text("insert_id", insert_id, 36), "time": n.timestamp(None), "properties": n.properties(properties)})
|
|
190
|
+
|
|
191
|
+
# ---------------- identity ----------------
|
|
192
|
+
|
|
193
|
+
def bind(self, login_id: str, identities: Mapping[str, str]) -> Optional[dict]:
|
|
194
|
+
"""Attach more identities (``email``, ``mobile``, ``unionid``, ``openid``, ``custom``). Email and mobile are SHA-256 hashed first."""
|
|
195
|
+
return self._enqueue({"type": "bind", "login_id": n.require_text("login_id", login_id, 200), "time": n.timestamp(None), "identities": n.identities(identities)})
|
|
196
|
+
|
|
197
|
+
def unbind(self, login_id: str, identity_type: str, value: str) -> Optional[dict]:
|
|
198
|
+
"""Remove one identity from the user."""
|
|
199
|
+
return self._enqueue({"type": "unbind", "login_id": n.require_text("login_id", login_id, 200), "time": n.timestamp(None), "identities": n.identities({identity_type: n.require_text("value", value)})})
|
|
200
|
+
|
|
201
|
+
# ---------------- profiles ----------------
|
|
202
|
+
|
|
203
|
+
def profile_set(self, login_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
|
|
204
|
+
return self._profile("profile_set", login_id, properties=n.properties(properties))
|
|
205
|
+
|
|
206
|
+
def profile_set_once(self, login_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
|
|
207
|
+
"""Set only the properties that are not set yet."""
|
|
208
|
+
return self._profile("profile_set_once", login_id, properties=n.properties(properties))
|
|
209
|
+
|
|
210
|
+
def profile_increment(self, login_id: str, increment: Mapping[str, Any]) -> Optional[dict]:
|
|
211
|
+
return self._profile("profile_increment", login_id, increment=n.increment(increment))
|
|
212
|
+
|
|
213
|
+
def profile_append(self, login_id: str, append: Mapping[str, Any]) -> Optional[dict]:
|
|
214
|
+
return self._profile("profile_append", login_id, append=n.append(append))
|
|
215
|
+
|
|
216
|
+
def profile_unset(self, login_id: str, keys: List[str]) -> Optional[dict]:
|
|
217
|
+
return self._profile("profile_unset", login_id, unset=n.unset_keys(keys))
|
|
218
|
+
|
|
219
|
+
# ---------------- items / accounts ----------------
|
|
220
|
+
|
|
221
|
+
def item_set(self, item_type: str, item_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
|
|
222
|
+
"""Upsert a dimension row (product, article, store …)."""
|
|
223
|
+
return self._enqueue({"type": "item_set", "item_type": n.require_text("item_type", item_type, 40), "item_id": n.require_text("item_id", item_id, 80), "time": n.timestamp(None), "properties": n.properties(properties)})
|
|
224
|
+
|
|
225
|
+
def item_delete(self, item_type: str, item_id: str) -> Optional[dict]:
|
|
226
|
+
return self._enqueue({"type": "item_delete", "item_type": n.require_text("item_type", item_type, 40), "item_id": n.require_text("item_id", item_id, 80), "time": n.timestamp(None)})
|
|
227
|
+
|
|
228
|
+
def group_set(self, group_type: str, group_id: str, properties: Mapping[str, Any]) -> Optional[dict]:
|
|
229
|
+
"""Set account (group) traits for ``group_type:group_id``."""
|
|
230
|
+
return self._enqueue({"type": "group_set", "group": n.group_key(group_type, group_id), "time": n.timestamp(None), "properties": n.properties(properties)})
|
|
231
|
+
|
|
232
|
+
# ---------------- sending ----------------
|
|
233
|
+
|
|
234
|
+
def flush(self) -> None:
|
|
235
|
+
"""Send everything queued now. Raises the first ``TapCubError`` if a batch could not be delivered."""
|
|
236
|
+
errors = self._drain()
|
|
237
|
+
if errors:
|
|
238
|
+
raise errors[0]
|
|
239
|
+
|
|
240
|
+
def close(self) -> None:
|
|
241
|
+
"""Stop the background thread and send what is queued (raises like ``flush``)."""
|
|
242
|
+
self._stop.set()
|
|
243
|
+
self._wake.set()
|
|
244
|
+
if self._thread and self._thread is not threading.current_thread():
|
|
245
|
+
self._thread.join(timeout=self.timeout * (self.max_retries + 1) + 5)
|
|
246
|
+
if getattr(self, "_atexit", None):
|
|
247
|
+
atexit.unregister(self._atexit)
|
|
248
|
+
self._atexit = None
|
|
249
|
+
self.flush()
|
|
250
|
+
|
|
251
|
+
def __enter__(self) -> "TapCub":
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
def __exit__(self, *exc: Any) -> None:
|
|
255
|
+
self.close()
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def queued(self) -> int:
|
|
259
|
+
return len(self._queue)
|
|
260
|
+
|
|
261
|
+
def _profile(self, kind: str, login_id: str, **body: Any) -> Optional[dict]:
|
|
262
|
+
return self._enqueue({"type": kind, "login_id": n.require_text("login_id", login_id, 200), "time": n.timestamp(None), **body})
|
|
263
|
+
|
|
264
|
+
def _enqueue(self, raw: Mapping[str, Any]) -> Optional[dict]:
|
|
265
|
+
ev = n.compact(raw)
|
|
266
|
+
if self.consumer != "batch":
|
|
267
|
+
return self._send([ev], self.consumer == "debug")
|
|
268
|
+
size = len(json.dumps(ev, separators=(",", ":"), ensure_ascii=False).encode("utf-8")) + 1
|
|
269
|
+
with self._lock:
|
|
270
|
+
self._queue.append((ev, size))
|
|
271
|
+
over = len(self._queue) - self.max_queue_size
|
|
272
|
+
for _ in range(max(0, over)):
|
|
273
|
+
self._queue.popleft()
|
|
274
|
+
self._dropped += 1
|
|
275
|
+
full = len(self._queue) >= self.max_batch
|
|
276
|
+
if full:
|
|
277
|
+
self._wake.set()
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
def _run(self) -> None:
|
|
281
|
+
while not self._stop.is_set():
|
|
282
|
+
self._wake.wait(self._flush_interval)
|
|
283
|
+
self._wake.clear()
|
|
284
|
+
if self._stop.is_set():
|
|
285
|
+
break
|
|
286
|
+
for e in self._drain():
|
|
287
|
+
self._report(e)
|
|
288
|
+
|
|
289
|
+
def _shutdown(self) -> None:
|
|
290
|
+
try:
|
|
291
|
+
self.close()
|
|
292
|
+
except TapCubError as e:
|
|
293
|
+
self._report(e)
|
|
294
|
+
|
|
295
|
+
def _report(self, err: TapCubError) -> None:
|
|
296
|
+
try:
|
|
297
|
+
self.on_error(err)
|
|
298
|
+
except Exception: # noqa: BLE001
|
|
299
|
+
self.logger.exception("[tapcub] on_error raised")
|
|
300
|
+
|
|
301
|
+
def _take(self) -> List[dict]:
|
|
302
|
+
with self._lock:
|
|
303
|
+
batch: List[dict] = []
|
|
304
|
+
size = 0
|
|
305
|
+
while self._queue and len(batch) < self.max_batch:
|
|
306
|
+
ev, b = self._queue[0]
|
|
307
|
+
if batch and size + b > MAX_BODY_BYTES:
|
|
308
|
+
break
|
|
309
|
+
self._queue.popleft()
|
|
310
|
+
batch.append(ev)
|
|
311
|
+
size += b
|
|
312
|
+
return batch
|
|
313
|
+
|
|
314
|
+
def _drain(self) -> List[TapCubError]:
|
|
315
|
+
errors: List[TapCubError] = []
|
|
316
|
+
with self._send_lock:
|
|
317
|
+
with self._lock:
|
|
318
|
+
dropped, self._dropped = self._dropped, 0
|
|
319
|
+
if dropped:
|
|
320
|
+
errors.append(TapCubError(f"TapCub: queue full, dropped {dropped} oldest event(s)", "QUEUE_FULL"))
|
|
321
|
+
while True:
|
|
322
|
+
batch = self._take()
|
|
323
|
+
if not batch:
|
|
324
|
+
break
|
|
325
|
+
try:
|
|
326
|
+
self._send(batch, False)
|
|
327
|
+
except TapCubError as e:
|
|
328
|
+
errors.append(e)
|
|
329
|
+
return errors
|
|
330
|
+
|
|
331
|
+
def _post(self, url: str, body: bytes, headers: Dict[str, str]) -> Tuple[int, Dict[str, str], bytes]:
|
|
332
|
+
"""One HTTP request. Returns (status, headers, body); raises OSError on network errors."""
|
|
333
|
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
334
|
+
try:
|
|
335
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r: # noqa: S310 (endpoint is configured by the caller)
|
|
336
|
+
return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
|
|
337
|
+
except urllib.error.HTTPError as e:
|
|
338
|
+
return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, e.read() or b""
|
|
339
|
+
|
|
340
|
+
def _send(self, events: List[dict], debug: bool) -> dict:
|
|
341
|
+
url = f"{self.endpoint}/api/v1/track" + ("?debug=1" if debug else "")
|
|
342
|
+
body = json.dumps({"events": events}, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
343
|
+
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", "User-Agent": f"tapcub-python/{VERSION}"}
|
|
344
|
+
if self.gzip:
|
|
345
|
+
body = _gzip.compress(body)
|
|
346
|
+
headers["Content-Encoding"] = "gzip"
|
|
347
|
+
last: Optional[TapCubError] = None
|
|
348
|
+
for attempt in range(self.max_retries + 1):
|
|
349
|
+
if attempt:
|
|
350
|
+
time.sleep(self._backoff(attempt, last))
|
|
351
|
+
try:
|
|
352
|
+
status, res_headers, raw = self._post(url, body, headers)
|
|
353
|
+
except (OSError, ValueError) as e:
|
|
354
|
+
last = TapCubError(f"TapCub: request failed: {e}", "NETWORK", events=events)
|
|
355
|
+
continue
|
|
356
|
+
try:
|
|
357
|
+
data = json.loads(raw.decode("utf-8")) if raw else {}
|
|
358
|
+
except ValueError:
|
|
359
|
+
data = {}
|
|
360
|
+
if not isinstance(data, dict):
|
|
361
|
+
data = {}
|
|
362
|
+
if 200 <= status < 300:
|
|
363
|
+
results = data.get("results") or []
|
|
364
|
+
if debug:
|
|
365
|
+
for r in results:
|
|
366
|
+
w = r.get("warnings") or []
|
|
367
|
+
self.logger.warning("[tapcub] debug #%s %s%s", r.get("index"), "ok" if r.get("ok") else "rejected", f" warnings={','.join(w)}" if w else "")
|
|
368
|
+
bad = [r for r in results if not r.get("ok")]
|
|
369
|
+
if bad:
|
|
370
|
+
raise TapCubError(
|
|
371
|
+
"TapCub: %d event(s) rejected: %s" % (len(bad), "; ".join(f"#{r.get('index')} {','.join(r.get('warnings') or []) or 'rejected'}" for r in bad)),
|
|
372
|
+
"EVENT_REJECTED", status, [events[r["index"]] for r in bad if isinstance(r.get("index"), int) and r["index"] < len(events)], bad,
|
|
373
|
+
)
|
|
374
|
+
data.setdefault("results", [])
|
|
375
|
+
return data
|
|
376
|
+
code = data.get("code") if isinstance(data.get("code"), str) else f"HTTP_{status}"
|
|
377
|
+
msg = data.get("message") if isinstance(data.get("message"), str) else raw[:200].decode("utf-8", "replace")
|
|
378
|
+
issues = data.get("issues")
|
|
379
|
+
detail = f" ({'; '.join(str(i) for i in issues[:3])})" if isinstance(issues, list) and issues else ""
|
|
380
|
+
last = TapCubError(f"TapCub: HTTP {status} {code}: {msg}{detail}", code, status, events)
|
|
381
|
+
try:
|
|
382
|
+
last.retry_after = float(res_headers.get("retry-after", "") or 0) or None
|
|
383
|
+
except ValueError:
|
|
384
|
+
last.retry_after = None
|
|
385
|
+
if status != 429 and status < 500:
|
|
386
|
+
raise last
|
|
387
|
+
assert last is not None
|
|
388
|
+
raise last
|
|
389
|
+
|
|
390
|
+
def _backoff(self, attempt: int, last: Optional[TapCubError]) -> float:
|
|
391
|
+
if last is not None and last.retry_after:
|
|
392
|
+
return min(60.0, last.retry_after)
|
|
393
|
+
base = min(30.0, self._retry_base * 2 ** (attempt - 1))
|
|
394
|
+
return base / 2 + random.random() * base / 2
|
tapcub/py.typed
ADDED
|
File without changes
|
tapcub/version.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tapcub
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TapCub server-side SDK for Python: events, sign-ups, identities, user profiles, items and accounts
|
|
5
|
+
Author-email: TapCub <hello@tapcub.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tapcub.com
|
|
8
|
+
Project-URL: Documentation, https://docs.tapcub.com/en/sdk/server#python
|
|
9
|
+
Project-URL: Source, https://github.com/tapcub/tapcub-sdk/tree/main/packages/sdk-server/python
|
|
10
|
+
Project-URL: Issues, https://github.com/tapcub/tapcub-sdk/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/tapcub/tapcub-sdk/blob/main/packages/sdk-server/python/CHANGELOG.md
|
|
12
|
+
Keywords: tapcub,analytics,server-side,events,tracking,product-analytics
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# tapcub
|
|
33
|
+
|
|
34
|
+
Server-side SDK for [TapCub](https://tapcub.com) on Python: business events, sign-ups, identities, user profiles, items and accounts, sent to `POST /api/v1/track`. Standard library only, typed (`py.typed`).
|
|
35
|
+
|
|
36
|
+
- Batches in memory and sends from a background thread (or sends on every call)
|
|
37
|
+
- Retries network errors, 5xx and 429 with exponential backoff, without changing the batch
|
|
38
|
+
- Cleans each event before it is queued, so one bad value never rejects a whole batch
|
|
39
|
+
- Hashes email and mobile identities (SHA-256) before they leave your server
|
|
40
|
+
|
|
41
|
+
Requires Python 3.8 or newer.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install tapcub
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import os
|
|
53
|
+
from tapcub import TapCub
|
|
54
|
+
|
|
55
|
+
tc = TapCub(os.environ["TAPCUB_API_KEY"]) # a wck_… key with the track:write scope
|
|
56
|
+
|
|
57
|
+
tc.track(
|
|
58
|
+
"purchase",
|
|
59
|
+
login_id=user.id,
|
|
60
|
+
insert_id=order.id, # idempotency key: retries of the same order are counted once
|
|
61
|
+
properties={"revenue": 99, "currency": "USD", "items": [{"item_id": "sku-1", "price": 99, "quantity": 1}]},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
tc.close() # also runs automatically when the interpreter exits
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Create the key in the TapCub console under **Configuration › Settings › API keys** with the **Server track** scope. It is shown once; keep it on the server.
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
`TapCub(api_key, endpoint=None, *, …)`
|
|
72
|
+
|
|
73
|
+
| Argument | Default | Description |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `api_key` | — | Required. Project key with the `track:write` (Server track) scope |
|
|
76
|
+
| `endpoint` | built in | Leave unset. Set it only when TapCub gives you a dedicated address (private deployment). A trailing `/api/v1/track` is removed |
|
|
77
|
+
| `consumer` | `"batch"` | `"batch"`: queue and send from a daemon thread. `"sync"`: send on every call and raise `TapCubError` on failure (calls return the server response). `"debug"`: like `"sync"` against `?debug=1` (validated, nothing stored), every result is logged |
|
|
78
|
+
| `max_batch` | `200` | Events per request (at most 1000). Requests are also kept under 1 MB |
|
|
79
|
+
| `flush_interval` | `3.0` | Seconds between sends |
|
|
80
|
+
| `max_queue_size` | `10000` | Queued events kept in memory; the oldest are dropped beyond this |
|
|
81
|
+
| `max_retries` | `5` | Retries for network errors, 5xx and 429 (honours `Retry-After`) |
|
|
82
|
+
| `timeout` | `10.0` | Seconds per request |
|
|
83
|
+
| `gzip` | `False` | Send `Content-Encoding: gzip` |
|
|
84
|
+
| `on_error` | log to the `tapcub` logger | Called with a `TapCubError` for every background failure |
|
|
85
|
+
| `logger` | `logging.getLogger("tapcub")` | |
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
Invalid arguments raise `ValueError` and queue nothing. The client is thread-safe; create one per process.
|
|
90
|
+
|
|
91
|
+
| Method | Event |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `track(event, *, login_id=None, login_id_type=None, device_id=None, session_id=None, insert_id=None, time=None, group=None, properties=None, context=None)` | A business event |
|
|
94
|
+
| `track_signup(login_id, *, device_id=None, invited_by=None, properties=None, context=None, …)` | `sign_up`; links the anonymous device to the new login ID |
|
|
95
|
+
| `bind(login_id, {"email": …, "mobile": …, "unionid": …})` | Attach more identities (email / mobile hashed in the SDK) |
|
|
96
|
+
| `unbind(login_id, identity_type, value)` | Remove one identity |
|
|
97
|
+
| `profile_set(login_id, props)` / `profile_set_once(login_id, props)` | Set profile properties (`$name`, `$email`, `$phone`, `$avatar` are display fields) |
|
|
98
|
+
| `profile_increment(login_id, {key: delta})` / `profile_append(login_id, {key: [values]})` / `profile_unset(login_id, [keys])` | Change profile properties |
|
|
99
|
+
| `item_set(item_type, item_id, props)` / `item_delete(item_type, item_id)` | Dimension rows (products, articles, stores) |
|
|
100
|
+
| `track_update(event, insert_id, props)` / `track_overwrite(event, insert_id, props)` | Merge into / replace the properties of a stored event |
|
|
101
|
+
| `group_set(group_type, group_id, props)` | Account (B2B group) traits; attach events with `track(…, group="company:acme")` |
|
|
102
|
+
| `flush()` | Send the queue now; raises the first `TapCubError` |
|
|
103
|
+
| `close()` | Stop the thread and `flush()`; `with TapCub(…) as tc:` does this for you |
|
|
104
|
+
| `from_request_headers(headers)` | `{"device_id", "session_id"}` from `X-Tc-Device` / `X-Tc-Session` (Django, Flask, Starlette, WSGI environ) |
|
|
105
|
+
|
|
106
|
+
`time` is milliseconds since the epoch or a `datetime` and defaults to the time of the call. Properties are flat: strings (cut to 1000 characters), numbers and booleans; dates become ISO-8601 strings; a list of dicts such as `items` is kept one level deep; other nested values are sent as JSON strings.
|
|
107
|
+
|
|
108
|
+
### Record where a sign-up came from
|
|
109
|
+
|
|
110
|
+
The browser script already knows the visit source (`tapcub.getAttribution()`). Send it to your backend with the sign-up form and pass it through unchanged:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# Django view
|
|
114
|
+
def signup(request):
|
|
115
|
+
data = json.loads(request.body)
|
|
116
|
+
user = create_user(data)
|
|
117
|
+
tc.track_signup(
|
|
118
|
+
user.id,
|
|
119
|
+
device_id=data.get("tapcubDeviceId"), # optional: links the anonymous visits
|
|
120
|
+
invited_by=data.get("inviterId"), # optional: referral program → properties.$invited_by
|
|
121
|
+
context={
|
|
122
|
+
"campaign": data.get("attribution"), # {source, medium, name, term, content, ref, click_id, landing_url, referrer, touched_at}
|
|
123
|
+
"ip": request.META.get("REMOTE_ADDR"), # ip + user_agent let TapCub infer the source when there is no cookie
|
|
124
|
+
"user_agent": request.headers.get("User-Agent"),
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
return JsonResponse({"ok": True})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Idempotency
|
|
131
|
+
|
|
132
|
+
The SDK resends a failed batch unchanged, but it does **not** generate `insert_id` for you. Pass a natural key (order ID, payment ID, ≤36 characters) for events that must not be counted twice; the server stores one event per `insert_id`.
|
|
133
|
+
|
|
134
|
+
## Errors
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from tapcub import TapCub, TapCubError
|
|
138
|
+
|
|
139
|
+
def report(err: TapCubError) -> None:
|
|
140
|
+
log.warning("tapcub %s (%s): %d events lost", err.code, err.status, len(err.events))
|
|
141
|
+
|
|
142
|
+
tc = TapCub(api_key, on_error=report)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`err.code` is the server code (`NO_API_KEY`, `BAD_API_KEY`, `VALIDATION`, …), `HTTP_<status>`, `NETWORK`, `EVENT_REJECTED` (the request succeeded but some events were rejected; see `err.results`) or `QUEUE_FULL`. 4xx responses other than 429 are not retried.
|
|
146
|
+
|
|
147
|
+
## Short-lived processes and workers
|
|
148
|
+
|
|
149
|
+
Scripts, Lambda handlers and Celery tasks should call `tc.flush()` before returning, or use `consumer="sync"`. With forking servers (gunicorn `--preload`, uWSGI) create the client after the fork, because the sender thread does not survive it.
|
|
150
|
+
|
|
151
|
+
## Check your integration
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
TapCub(api_key, consumer="debug").track("purchase", login_id="u1", properties={"revenue": 1})
|
|
155
|
+
# logs "debug #0 ok" (or the warnings) and stores nothing
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Documentation
|
|
159
|
+
|
|
160
|
+
[docs.tapcub.com → Server SDKs](https://docs.tapcub.com/en/sdk/server#python). Behaviour shared by every TapCub server SDK is specified by the conformance vectors in [`packages/shared/test-vectors/track`](https://github.com/tapcub/tapcub-sdk/tree/main/packages/shared/test-vectors/track).
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tapcub/__init__.py,sha256=TXJjsc9XvslAewXdiJkML7JcDWgJsSp2Nux3bml1vSo,555
|
|
2
|
+
tapcub/_normalize.py,sha256=gJUjfhNYwzW_qUMLxpo5EkwPvNRqHi7VINdf0RH4rGU,8313
|
|
3
|
+
tapcub/client.py,sha256=kaGF5MRw2tk-UdTq7EoAqgMX2R74WkNwufqrUedCJkI,18176
|
|
4
|
+
tapcub/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
tapcub/version.py,sha256=7A1L6aMdetMdBEyE0CrEW421HDdf4mSPmJ6Nf2hC0lc,91
|
|
6
|
+
tapcub-0.1.0.dist-info/licenses/LICENSE,sha256=90A_5sifmt1zqDMduHCaUvIJLVzx6XWjwbeqe8aW_OA,1063
|
|
7
|
+
tapcub-0.1.0.dist-info/METADATA,sha256=Jg3GC6rnXyswOugQqN7P_Ft402QoO23XWW2apxSeeZg,8418
|
|
8
|
+
tapcub-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
tapcub-0.1.0.dist-info/top_level.txt,sha256=LfmhldBVVDkOOg9vKkA64NROyLn1u7H-0r8p18WOS2I,7
|
|
10
|
+
tapcub-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TapCub
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tapcub
|