tokenome 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.
- tokenome/__init__.py +17 -0
- tokenome/client.py +350 -0
- tokenome/config.py +37 -0
- tokenome/context.py +87 -0
- tokenome/delivery/__init__.py +7 -0
- tokenome/delivery/result.py +55 -0
- tokenome/delivery/sender.py +163 -0
- tokenome/delivery/worker.py +296 -0
- tokenome/errors.py +14 -0
- tokenome/limits.py +5 -0
- tokenome/models.py +69 -0
- tokenome/providers/__init__.py +6 -0
- tokenome/providers/anthropic.py +100 -0
- tokenome/providers/openai/__init__.py +919 -0
- tokenome/serialization.py +68 -0
- tokenome/spool/__init__.py +7 -0
- tokenome/spool/memory.py +148 -0
- tokenome/spool/protocol.py +48 -0
- tokenome/spool/sqlite.py +351 -0
- tokenome/wrapper.py +17 -0
- tokenome-0.1.0.dist-info/METADATA +602 -0
- tokenome-0.1.0.dist-info/RECORD +24 -0
- tokenome-0.1.0.dist-info/WHEEL +4 -0
- tokenome-0.1.0.dist-info/licenses/LICENSE +21 -0
tokenome/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .client import TokenLens
|
|
2
|
+
from .context import (
|
|
3
|
+
clear_context,
|
|
4
|
+
clear_default_context,
|
|
5
|
+
get_context,
|
|
6
|
+
set_context,
|
|
7
|
+
set_default_context,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"TokenLens",
|
|
12
|
+
"set_context",
|
|
13
|
+
"set_default_context",
|
|
14
|
+
"get_context",
|
|
15
|
+
"clear_context",
|
|
16
|
+
"clear_default_context",
|
|
17
|
+
]
|
tokenome/client.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib import import_module
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .config import (
|
|
12
|
+
DEFAULT_DROP_POLICY,
|
|
13
|
+
DEFAULT_DURABILITY,
|
|
14
|
+
DEFAULT_ENDPOINT,
|
|
15
|
+
DEFAULT_MAX_SPOOL_AGE_DAYS,
|
|
16
|
+
DEFAULT_MAX_SPOOL_BYTES,
|
|
17
|
+
DEFAULT_QUEUE_MAXSIZE,
|
|
18
|
+
DEFAULT_RETRY_MAX_DELAY_SECONDS,
|
|
19
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
20
|
+
TokenomeConfig,
|
|
21
|
+
)
|
|
22
|
+
from .context import clear_context, clear_default_context
|
|
23
|
+
from .delivery.sender import HttpSender
|
|
24
|
+
from .delivery.worker import DeliveryWorker
|
|
25
|
+
from .errors import MissingOptionalDependencyError, UnsupportedProviderVersionError
|
|
26
|
+
from .limits import DEFAULT_MAX_PAYLOAD_BYTES
|
|
27
|
+
from .models import TelemetryEvent, WrapperDefaults
|
|
28
|
+
from .providers.openai import OPENAI_SUPPORTED_VERSION_RANGE
|
|
29
|
+
from .serialization import sanitize_tags
|
|
30
|
+
from .spool import SQLiteEventSpool
|
|
31
|
+
from .spool.memory import MemorySpool
|
|
32
|
+
from .wrapper import wrap_provider_client
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("tokenome")
|
|
35
|
+
|
|
36
|
+
_ENV_API_KEY = "TOKENLENS_API_KEY"
|
|
37
|
+
_ENV_ENDPOINT = "TOKENLENS_ENDPOINT"
|
|
38
|
+
_ENV_DEBUG = "TOKENLENS_DEBUG"
|
|
39
|
+
_ENV_ENABLED = "TOKENLENS_ENABLED"
|
|
40
|
+
_ENV_TAGS = "TOKENLENS_TAGS"
|
|
41
|
+
_ENV_TIMEOUT = "TOKENLENS_TIMEOUT_SECONDS"
|
|
42
|
+
_ENV_QUEUE_MAXSIZE = "TOKENLENS_QUEUE_MAXSIZE"
|
|
43
|
+
_ENV_PROJECT_ID = "TOKENLENS_PROJECT_ID"
|
|
44
|
+
_ENV_ENVIRONMENT = "TOKENLENS_ENVIRONMENT"
|
|
45
|
+
_ENV_DURABILITY = "TOKENLENS_DURABILITY"
|
|
46
|
+
_ENV_SPOOL_PATH = "TOKENLENS_SPOOL_PATH"
|
|
47
|
+
_ENV_MAX_SPOOL_BYTES = "TOKENLENS_MAX_SPOOL_BYTES"
|
|
48
|
+
_ENV_MAX_SPOOL_AGE_DAYS = "TOKENLENS_MAX_SPOOL_AGE_DAYS"
|
|
49
|
+
_ENV_RETRY_MAX_DELAY_SECONDS = "TOKENLENS_RETRY_MAX_DELAY_SECONDS"
|
|
50
|
+
_ENV_DROP_POLICY = "TOKENLENS_DROP_POLICY"
|
|
51
|
+
|
|
52
|
+
_TRUTHY = {"1", "true", "t", "yes", "y", "on"}
|
|
53
|
+
_FALSY = {"0", "false", "f", "no", "n", "off"}
|
|
54
|
+
_ALLOWED_DURABILITY = {"durable_local", "best_effort", "agent"}
|
|
55
|
+
_ALLOWED_DROP_POLICY = {"drop_oldest", "drop_newest", "block"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class _TokenLensState:
|
|
60
|
+
config: TokenomeConfig
|
|
61
|
+
spool: SQLiteEventSpool | MemorySpool
|
|
62
|
+
sender: HttpSender
|
|
63
|
+
worker: DeliveryWorker
|
|
64
|
+
|
|
65
|
+
def enqueue(self, event: TelemetryEvent) -> None:
|
|
66
|
+
if not self.config.enabled:
|
|
67
|
+
return
|
|
68
|
+
try:
|
|
69
|
+
stored = self.spool.append(event)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
# Spool corruption or unexpected error — log at debug and drop.
|
|
72
|
+
logger.debug("spool append failed for event %s: %s", event.event_id, exc)
|
|
73
|
+
return
|
|
74
|
+
if stored:
|
|
75
|
+
self.worker.notify_event_available()
|
|
76
|
+
else:
|
|
77
|
+
# Capacity drop — spool is full or event oversized.
|
|
78
|
+
logger.debug("spool dropped event %s (capacity or size limit)", event.event_id)
|
|
79
|
+
|
|
80
|
+
def flush(self) -> bool:
|
|
81
|
+
return self.worker.flush(timeout=10.0)
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
self.worker.close(timeout=2.0)
|
|
85
|
+
self.sender.close()
|
|
86
|
+
self.spool.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_default_client: TokenLens | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TokenLens:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
api_key: str,
|
|
97
|
+
project_id: str | None = None,
|
|
98
|
+
environment: str | None = None,
|
|
99
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
100
|
+
enabled: bool = True,
|
|
101
|
+
debug: bool = False,
|
|
102
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
103
|
+
queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE,
|
|
104
|
+
durability: str = DEFAULT_DURABILITY,
|
|
105
|
+
spool_path: str | None = None,
|
|
106
|
+
max_spool_bytes: int = DEFAULT_MAX_SPOOL_BYTES,
|
|
107
|
+
max_spool_age_days: int = DEFAULT_MAX_SPOOL_AGE_DAYS,
|
|
108
|
+
retry_max_delay_seconds: float = DEFAULT_RETRY_MAX_DELAY_SECONDS,
|
|
109
|
+
drop_policy: str = DEFAULT_DROP_POLICY,
|
|
110
|
+
default_tags: dict[str, object] | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.project_id = project_id
|
|
113
|
+
self.environment = environment
|
|
114
|
+
normalized_durability = _parse_choice(
|
|
115
|
+
durability,
|
|
116
|
+
env_name="durability",
|
|
117
|
+
allowed=_ALLOWED_DURABILITY,
|
|
118
|
+
)
|
|
119
|
+
normalized_drop_policy = _parse_choice(
|
|
120
|
+
drop_policy,
|
|
121
|
+
env_name="drop_policy",
|
|
122
|
+
allowed=_ALLOWED_DROP_POLICY,
|
|
123
|
+
)
|
|
124
|
+
if normalized_durability == "agent":
|
|
125
|
+
raise ValueError("durability='agent' is documented future mode and not implemented yet")
|
|
126
|
+
|
|
127
|
+
sanitized_default_tags = sanitize_tags(default_tags) or {}
|
|
128
|
+
config = TokenomeConfig(
|
|
129
|
+
api_key=api_key,
|
|
130
|
+
endpoint=endpoint,
|
|
131
|
+
enabled=enabled,
|
|
132
|
+
debug=debug,
|
|
133
|
+
timeout_seconds=timeout_seconds,
|
|
134
|
+
queue_maxsize=queue_maxsize,
|
|
135
|
+
durability=normalized_durability, # type: ignore[arg-type]
|
|
136
|
+
spool_path=spool_path,
|
|
137
|
+
max_spool_bytes=max_spool_bytes,
|
|
138
|
+
max_spool_age_days=max_spool_age_days,
|
|
139
|
+
retry_max_delay_seconds=retry_max_delay_seconds,
|
|
140
|
+
drop_policy=normalized_drop_policy, # type: ignore[arg-type]
|
|
141
|
+
default_tags=sanitized_default_tags,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if normalized_durability == "durable_local":
|
|
145
|
+
spool: SQLiteEventSpool | MemorySpool = SQLiteEventSpool(
|
|
146
|
+
path=spool_path,
|
|
147
|
+
max_bytes=max_spool_bytes,
|
|
148
|
+
max_age_days=max_spool_age_days,
|
|
149
|
+
drop_policy=normalized_drop_policy, # type: ignore[arg-type]
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
spool = MemorySpool(max_size=queue_maxsize)
|
|
153
|
+
|
|
154
|
+
sender = HttpSender(
|
|
155
|
+
endpoint=endpoint,
|
|
156
|
+
api_key=api_key,
|
|
157
|
+
timeout_seconds=timeout_seconds,
|
|
158
|
+
debug=debug,
|
|
159
|
+
)
|
|
160
|
+
worker = DeliveryWorker(
|
|
161
|
+
spool=spool,
|
|
162
|
+
sender=sender,
|
|
163
|
+
max_payload_bytes=DEFAULT_MAX_PAYLOAD_BYTES,
|
|
164
|
+
shutdown_timeout_seconds=2.0,
|
|
165
|
+
)
|
|
166
|
+
worker.start()
|
|
167
|
+
|
|
168
|
+
self._state = _TokenLensState(
|
|
169
|
+
config=config,
|
|
170
|
+
spool=spool,
|
|
171
|
+
sender=sender,
|
|
172
|
+
worker=worker,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def config(self) -> TokenomeConfig:
|
|
177
|
+
return self._state.config
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def init(cls, **kwargs: Any) -> TokenLens:
|
|
181
|
+
global _default_client
|
|
182
|
+
if _default_client is not None:
|
|
183
|
+
_default_client.close()
|
|
184
|
+
_default_client = cls(**kwargs)
|
|
185
|
+
return _default_client
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def init_from_env(cls) -> TokenLens:
|
|
189
|
+
api_key = os.getenv(_ENV_API_KEY)
|
|
190
|
+
if not api_key:
|
|
191
|
+
raise ValueError(f"Missing required environment variable: {_ENV_API_KEY}")
|
|
192
|
+
return cls.init(
|
|
193
|
+
api_key=api_key,
|
|
194
|
+
project_id=os.getenv(_ENV_PROJECT_ID),
|
|
195
|
+
environment=os.getenv(_ENV_ENVIRONMENT),
|
|
196
|
+
endpoint=os.getenv(_ENV_ENDPOINT, DEFAULT_ENDPOINT),
|
|
197
|
+
enabled=_parse_bool(os.getenv(_ENV_ENABLED), default=True),
|
|
198
|
+
debug=_parse_bool(os.getenv(_ENV_DEBUG), default=False),
|
|
199
|
+
timeout_seconds=float(os.getenv(_ENV_TIMEOUT, str(DEFAULT_TIMEOUT_SECONDS))),
|
|
200
|
+
queue_maxsize=int(os.getenv(_ENV_QUEUE_MAXSIZE, "10000")),
|
|
201
|
+
durability=os.getenv(_ENV_DURABILITY, DEFAULT_DURABILITY),
|
|
202
|
+
spool_path=os.getenv(_ENV_SPOOL_PATH),
|
|
203
|
+
max_spool_bytes=int(os.getenv(_ENV_MAX_SPOOL_BYTES, str(DEFAULT_MAX_SPOOL_BYTES))),
|
|
204
|
+
max_spool_age_days=int(
|
|
205
|
+
os.getenv(_ENV_MAX_SPOOL_AGE_DAYS, str(DEFAULT_MAX_SPOOL_AGE_DAYS))
|
|
206
|
+
),
|
|
207
|
+
retry_max_delay_seconds=float(
|
|
208
|
+
os.getenv(_ENV_RETRY_MAX_DELAY_SECONDS, str(DEFAULT_RETRY_MAX_DELAY_SECONDS))
|
|
209
|
+
),
|
|
210
|
+
drop_policy=os.getenv(_ENV_DROP_POLICY, DEFAULT_DROP_POLICY),
|
|
211
|
+
default_tags=_parse_tags(os.getenv(_ENV_TAGS)),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def is_initialized(cls) -> bool:
|
|
216
|
+
return _default_client is not None
|
|
217
|
+
|
|
218
|
+
def wrap(
|
|
219
|
+
self,
|
|
220
|
+
client: Any,
|
|
221
|
+
*,
|
|
222
|
+
provider: str | None = None,
|
|
223
|
+
route: str | None = None,
|
|
224
|
+
feature: str | None = None,
|
|
225
|
+
user_id: str | None = None,
|
|
226
|
+
user_id_hash: str | None = None,
|
|
227
|
+
session_id: str | None = None,
|
|
228
|
+
tags: dict[str, object] | None = None,
|
|
229
|
+
) -> Any:
|
|
230
|
+
defaults = WrapperDefaults(
|
|
231
|
+
provider=provider or _guess_provider(client),
|
|
232
|
+
project_id=self.project_id,
|
|
233
|
+
environment=self.environment,
|
|
234
|
+
route=route,
|
|
235
|
+
feature=feature,
|
|
236
|
+
user_id=user_id,
|
|
237
|
+
user_id_hash=user_id_hash,
|
|
238
|
+
session_id=session_id,
|
|
239
|
+
tags={**self.config.default_tags, **(sanitize_tags(tags) or {})},
|
|
240
|
+
)
|
|
241
|
+
return wrap_provider_client(client, self._state.enqueue, defaults)
|
|
242
|
+
|
|
243
|
+
def wrap_openai(self, client: Any, **context_overrides: Any) -> Any:
|
|
244
|
+
return self.wrap(client, provider="openai", **context_overrides)
|
|
245
|
+
|
|
246
|
+
def OpenAI(self, *args: Any, **kwargs: Any) -> Any:
|
|
247
|
+
openai = _import_openai_module()
|
|
248
|
+
_ensure_supported_openai_version()
|
|
249
|
+
client = openai.OpenAI(*args, **kwargs)
|
|
250
|
+
return self.wrap_openai(client)
|
|
251
|
+
|
|
252
|
+
def AsyncOpenAI(self, *args: Any, **kwargs: Any) -> Any:
|
|
253
|
+
openai = _import_openai_module()
|
|
254
|
+
_ensure_supported_openai_version()
|
|
255
|
+
client = openai.AsyncOpenAI(*args, **kwargs)
|
|
256
|
+
return self.wrap_openai(client)
|
|
257
|
+
|
|
258
|
+
def flush(self) -> bool:
|
|
259
|
+
return self._state.flush()
|
|
260
|
+
|
|
261
|
+
def close(self) -> None:
|
|
262
|
+
global _default_client
|
|
263
|
+
self._state.close()
|
|
264
|
+
if _default_client is self:
|
|
265
|
+
_default_client = None
|
|
266
|
+
clear_context()
|
|
267
|
+
clear_default_context()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_state() -> _TokenLensState | None:
|
|
271
|
+
if _default_client is None:
|
|
272
|
+
return None
|
|
273
|
+
return _default_client._state
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _parse_bool(raw: str | None, *, default: bool) -> bool:
|
|
277
|
+
if raw is None:
|
|
278
|
+
return default
|
|
279
|
+
normalized = raw.strip().lower()
|
|
280
|
+
if normalized in _TRUTHY:
|
|
281
|
+
return True
|
|
282
|
+
if normalized in _FALSY:
|
|
283
|
+
return False
|
|
284
|
+
raise ValueError(f"Invalid boolean value: {raw}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _parse_tags(raw: str | None) -> dict[str, object] | None:
|
|
288
|
+
if not raw:
|
|
289
|
+
return None
|
|
290
|
+
data = json.loads(raw)
|
|
291
|
+
if not isinstance(data, dict):
|
|
292
|
+
raise ValueError(f"{_ENV_TAGS} must be a JSON object")
|
|
293
|
+
return dict(data)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _parse_choice(raw: str, *, env_name: str, allowed: set[str]) -> str:
|
|
297
|
+
normalized = raw.strip().lower()
|
|
298
|
+
if normalized not in allowed:
|
|
299
|
+
options = ", ".join(sorted(allowed))
|
|
300
|
+
raise ValueError(f"Invalid {env_name} value: {raw}. Expected one of: {options}")
|
|
301
|
+
return normalized
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _guess_provider(client: Any) -> str:
|
|
305
|
+
module = type(client).__module__.lower()
|
|
306
|
+
if "anthropic" in module:
|
|
307
|
+
return "anthropic"
|
|
308
|
+
if "openai" in module:
|
|
309
|
+
return "openai"
|
|
310
|
+
if hasattr(client, "responses") or (
|
|
311
|
+
hasattr(client, "chat") and hasattr(client.chat, "completions")
|
|
312
|
+
):
|
|
313
|
+
return "openai-compatible"
|
|
314
|
+
return type(client).__name__.lower()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _import_openai_module() -> Any:
|
|
318
|
+
try:
|
|
319
|
+
return import_module("openai")
|
|
320
|
+
except ModuleNotFoundError as exc:
|
|
321
|
+
raise MissingOptionalDependencyError(
|
|
322
|
+
'OpenAI integration requires `pip install "tokenome[openai]"`.'
|
|
323
|
+
) from exc
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _ensure_supported_openai_version() -> str:
|
|
327
|
+
try:
|
|
328
|
+
installed = version("openai")
|
|
329
|
+
except PackageNotFoundError as exc:
|
|
330
|
+
raise MissingOptionalDependencyError(
|
|
331
|
+
'OpenAI integration requires `pip install "tokenome[openai]"`.'
|
|
332
|
+
) from exc
|
|
333
|
+
|
|
334
|
+
major = _major_version(installed)
|
|
335
|
+
if major < 2:
|
|
336
|
+
raise UnsupportedProviderVersionError(
|
|
337
|
+
"Unsupported openai version "
|
|
338
|
+
f"`{installed}`. Supported range: {OPENAI_SUPPORTED_VERSION_RANGE}."
|
|
339
|
+
)
|
|
340
|
+
if major >= 3:
|
|
341
|
+
raise UnsupportedProviderVersionError(
|
|
342
|
+
"Untested openai version "
|
|
343
|
+
f"`{installed}`. Supported range: {OPENAI_SUPPORTED_VERSION_RANGE}."
|
|
344
|
+
)
|
|
345
|
+
return installed
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _major_version(raw: str) -> int:
|
|
349
|
+
head = raw.split(".", maxsplit=1)[0]
|
|
350
|
+
return int(head)
|
tokenome/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
DEFAULT_ENDPOINT = "https://api.tokenome.ai/v1/events/batch"
|
|
7
|
+
DEFAULT_QUEUE_MAXSIZE = 10_000
|
|
8
|
+
DEFAULT_TIMEOUT_SECONDS = 1.5
|
|
9
|
+
DEFAULT_DURABILITY: Literal["durable_local", "best_effort", "agent"] = "durable_local"
|
|
10
|
+
DEFAULT_MAX_SPOOL_BYTES = 100 * 1024 * 1024
|
|
11
|
+
DEFAULT_MAX_SPOOL_AGE_DAYS = 7
|
|
12
|
+
DEFAULT_RETRY_MAX_DELAY_SECONDS = 900.0
|
|
13
|
+
DEFAULT_DROP_POLICY: Literal["drop_oldest", "drop_newest", "block"] = "drop_oldest"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True, frozen=True)
|
|
17
|
+
class TokenomeConfig:
|
|
18
|
+
"""Immutable configuration for Tokenome SDK.
|
|
19
|
+
|
|
20
|
+
Batch sizing and flush interval are not exposed as constructor parameters
|
|
21
|
+
because they are part of the SDK/server contract. Changing them risks
|
|
22
|
+
413 Payload Too Large, rate limits, or server-side rejection.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
api_key: str
|
|
26
|
+
endpoint: str = DEFAULT_ENDPOINT
|
|
27
|
+
enabled: bool = True
|
|
28
|
+
debug: bool = False
|
|
29
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
|
30
|
+
queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE
|
|
31
|
+
durability: Literal["durable_local", "best_effort", "agent"] = DEFAULT_DURABILITY
|
|
32
|
+
spool_path: str | None = None
|
|
33
|
+
max_spool_bytes: int = DEFAULT_MAX_SPOOL_BYTES
|
|
34
|
+
max_spool_age_days: int = DEFAULT_MAX_SPOOL_AGE_DAYS
|
|
35
|
+
retry_max_delay_seconds: float = DEFAULT_RETRY_MAX_DELAY_SECONDS
|
|
36
|
+
drop_policy: Literal["drop_oldest", "drop_newest", "block"] = DEFAULT_DROP_POLICY
|
|
37
|
+
default_tags: dict[str, object] = field(default_factory=dict)
|
tokenome/context.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class TrackingContext:
|
|
9
|
+
route: str | None = None
|
|
10
|
+
feature: str | None = None
|
|
11
|
+
user_id: str | None = None
|
|
12
|
+
user_id_hash: str | None = None
|
|
13
|
+
session_id: str | None = None
|
|
14
|
+
tags: dict[str, object] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_ctx: ContextVar[TrackingContext | None] = ContextVar("tokenome_context", default=None)
|
|
18
|
+
_default_context = TrackingContext()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _merge(base: TrackingContext, override: TrackingContext | None) -> TrackingContext:
|
|
22
|
+
if override is None:
|
|
23
|
+
override = TrackingContext()
|
|
24
|
+
return TrackingContext(
|
|
25
|
+
route=override.route if override.route is not None else base.route,
|
|
26
|
+
feature=override.feature if override.feature is not None else base.feature,
|
|
27
|
+
user_id=override.user_id if override.user_id is not None else base.user_id,
|
|
28
|
+
user_id_hash=(
|
|
29
|
+
override.user_id_hash if override.user_id_hash is not None else base.user_id_hash
|
|
30
|
+
),
|
|
31
|
+
session_id=override.session_id if override.session_id is not None else base.session_id,
|
|
32
|
+
tags={**base.tags, **override.tags},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_context(
|
|
37
|
+
*,
|
|
38
|
+
route: str | None = None,
|
|
39
|
+
feature: str | None = None,
|
|
40
|
+
user_id: str | None = None,
|
|
41
|
+
user_id_hash: str | None = None,
|
|
42
|
+
session_id: str | None = None,
|
|
43
|
+
tags: dict[str, object] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
_ctx.set(
|
|
46
|
+
TrackingContext(
|
|
47
|
+
route=route,
|
|
48
|
+
feature=feature,
|
|
49
|
+
user_id=user_id,
|
|
50
|
+
user_id_hash=user_id_hash,
|
|
51
|
+
session_id=session_id,
|
|
52
|
+
tags=dict(tags or {}),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_default_context(
|
|
58
|
+
*,
|
|
59
|
+
route: str | None = None,
|
|
60
|
+
feature: str | None = None,
|
|
61
|
+
user_id: str | None = None,
|
|
62
|
+
user_id_hash: str | None = None,
|
|
63
|
+
session_id: str | None = None,
|
|
64
|
+
tags: dict[str, object] | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
global _default_context
|
|
67
|
+
_default_context = TrackingContext(
|
|
68
|
+
route=route,
|
|
69
|
+
feature=feature,
|
|
70
|
+
user_id=user_id,
|
|
71
|
+
user_id_hash=user_id_hash,
|
|
72
|
+
session_id=session_id,
|
|
73
|
+
tags=dict(tags or {}),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_context() -> TrackingContext:
|
|
78
|
+
return _merge(_default_context, _ctx.get())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def clear_context() -> None:
|
|
82
|
+
_ctx.set(None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def clear_default_context() -> None:
|
|
86
|
+
global _default_context
|
|
87
|
+
_default_context = TrackingContext()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class SendResult:
|
|
9
|
+
"""Result of a batch send attempt."""
|
|
10
|
+
|
|
11
|
+
ok: bool = False
|
|
12
|
+
retryable: bool = False
|
|
13
|
+
status_code: int | None = None
|
|
14
|
+
error: str | None = None
|
|
15
|
+
retry_after_seconds: int | None = None
|
|
16
|
+
extra: dict[str, Any] | None = None
|
|
17
|
+
acked: list[str] | None = None
|
|
18
|
+
dropped: list[str] | None = None
|
|
19
|
+
retryable_ids: list[str] | None = None
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def success(
|
|
23
|
+
cls, *, acked: list[str] | None = None, status_code: int | None = None
|
|
24
|
+
) -> SendResult:
|
|
25
|
+
return cls(ok=True, retryable=False, status_code=status_code, error=None, acked=acked or [])
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def retryable_error(
|
|
29
|
+
cls,
|
|
30
|
+
*,
|
|
31
|
+
status_code: int | None = None,
|
|
32
|
+
error: str | None = None,
|
|
33
|
+
retry_after_seconds: int | None = None,
|
|
34
|
+
retryable_ids: list[str] | None = None,
|
|
35
|
+
) -> SendResult:
|
|
36
|
+
return cls(
|
|
37
|
+
ok=False,
|
|
38
|
+
retryable=True,
|
|
39
|
+
status_code=status_code,
|
|
40
|
+
error=error,
|
|
41
|
+
retry_after_seconds=retry_after_seconds,
|
|
42
|
+
retryable_ids=retryable_ids or [],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def fatal_error(
|
|
47
|
+
cls,
|
|
48
|
+
*,
|
|
49
|
+
status_code: int | None = None,
|
|
50
|
+
error: str | None = None,
|
|
51
|
+
dropped: list[str] | None = None,
|
|
52
|
+
) -> SendResult:
|
|
53
|
+
return cls(
|
|
54
|
+
ok=False, retryable=False, status_code=status_code, error=error, dropped=dropped or []
|
|
55
|
+
)
|