tokenmeter-sdk 0.2.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.
- tokenmeter/__init__.py +49 -0
- tokenmeter/_buffer.py +201 -0
- tokenmeter/_client.py +78 -0
- tokenmeter/_context.py +131 -0
- tokenmeter/_diagnose.py +57 -0
- tokenmeter/_ids.py +45 -0
- tokenmeter/_normalize.py +239 -0
- tokenmeter/_optimization.py +97 -0
- tokenmeter/_preflight.py +182 -0
- tokenmeter/_read_api.py +50 -0
- tokenmeter/_topic.py +91 -0
- tokenmeter/_transport.py +82 -0
- tokenmeter/_wrap.py +807 -0
- tokenmeter_sdk-0.2.0.dist-info/METADATA +221 -0
- tokenmeter_sdk-0.2.0.dist-info/RECORD +18 -0
- tokenmeter_sdk-0.2.0.dist-info/WHEEL +5 -0
- tokenmeter_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- tokenmeter_sdk-0.2.0.dist-info/top_level.txt +1 -0
tokenmeter/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""TokenMeter Python SDK.
|
|
2
|
+
|
|
3
|
+
import tokenmeter
|
|
4
|
+
tokenmeter.init(ingest_key="tl_live_...")
|
|
5
|
+
client = tokenmeter.wrap(OpenAI())
|
|
6
|
+
|
|
7
|
+
with tokenmeter.action("generate-report", customer_id="acme", feature="reports"):
|
|
8
|
+
client.chat.completions.create(...)
|
|
9
|
+
"""
|
|
10
|
+
from ._client import init
|
|
11
|
+
from ._context import TraceContext, action, attach, current
|
|
12
|
+
from ._diagnose import diagnose
|
|
13
|
+
from ._preflight import GovernanceDenied, GovernanceUnavailable, PreflightDecision, governed, preflight
|
|
14
|
+
from ._wrap import wrap, wrap_openai_compatible
|
|
15
|
+
from ._read_api import ReadApiClient, ReadApiError, TokenMeterReadClient
|
|
16
|
+
from ._topic import keyword_topic_classifier, latest_user_text
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"init",
|
|
20
|
+
"wrap",
|
|
21
|
+
"wrap_openai_compatible",
|
|
22
|
+
"action",
|
|
23
|
+
"current",
|
|
24
|
+
"attach",
|
|
25
|
+
"TraceContext",
|
|
26
|
+
"diagnose",
|
|
27
|
+
"flush",
|
|
28
|
+
"preflight",
|
|
29
|
+
"governed",
|
|
30
|
+
"PreflightDecision",
|
|
31
|
+
"GovernanceDenied",
|
|
32
|
+
"GovernanceUnavailable",
|
|
33
|
+
"ReadApiClient",
|
|
34
|
+
"TokenMeterReadClient",
|
|
35
|
+
"ReadApiError",
|
|
36
|
+
"keyword_topic_classifier",
|
|
37
|
+
"latest_user_text",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__version__ = "0.2.0"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def flush(deadline_s: float = 2.0) -> None:
|
|
44
|
+
"""Best-effort synchronous flush. Never raises."""
|
|
45
|
+
from ._client import get_state
|
|
46
|
+
|
|
47
|
+
state = get_state()
|
|
48
|
+
if state is not None:
|
|
49
|
+
state.flusher.flush_best_effort(deadline_s=deadline_s)
|
tokenmeter/_buffer.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Ring buffer + background flush thread (PRD §7.1).
|
|
2
|
+
|
|
3
|
+
Flush every 2s or 500 events, whichever first. Buffer full -> drop OLDEST,
|
|
4
|
+
count the drop, ship the count in the next batch (never silent).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import atexit
|
|
9
|
+
import collections
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Callable, Deque, List, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("tokenmeter")
|
|
16
|
+
|
|
17
|
+
DEFAULT_MAXSIZE = 10_000
|
|
18
|
+
DEFAULT_FLUSH_INTERVAL_S = 2.0
|
|
19
|
+
DEFAULT_FLUSH_BATCH_SIZE = 500
|
|
20
|
+
# DECISIONS.md §8: sustained rate limiting must be LOUD, not a silent spin.
|
|
21
|
+
# 5 consecutive 429s at the default 2s flush interval is ~10s of sustained
|
|
22
|
+
# rejection -- past one unlucky fixed-window boundary straddle, short of
|
|
23
|
+
# letting the ~10k-event ring buffer fill silently.
|
|
24
|
+
CONSECUTIVE_429_WARN_THRESHOLD = 5
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RingBuffer:
|
|
28
|
+
"""Thread-safe, fixed-capacity, drop-oldest-on-overflow queue.
|
|
29
|
+
|
|
30
|
+
`collections.deque(maxlen=N)` already evicts from the opposite end on
|
|
31
|
+
overflow (stdlib does it, rung 3) -- this class only adds the lock and the
|
|
32
|
+
dropped-event counter the eviction doesn't tell us about on its own.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, maxsize: int = DEFAULT_MAXSIZE) -> None:
|
|
36
|
+
self._dq: Deque[dict] = collections.deque(maxlen=maxsize)
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._dropped = 0
|
|
39
|
+
|
|
40
|
+
def append(self, event: dict) -> None:
|
|
41
|
+
with self._lock:
|
|
42
|
+
if len(self._dq) >= (self._dq.maxlen or 0):
|
|
43
|
+
self._dropped += 1 # about to evict the oldest via maxlen
|
|
44
|
+
self._dq.append(event)
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
with self._lock:
|
|
48
|
+
return len(self._dq)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def capacity(self) -> int:
|
|
52
|
+
return self._dq.maxlen or 0
|
|
53
|
+
|
|
54
|
+
def drain(self, max_items: Optional[int] = None) -> List[dict]:
|
|
55
|
+
"""Pop up to max_items (or all) events. Returns [] if empty."""
|
|
56
|
+
with self._lock:
|
|
57
|
+
n = len(self._dq) if max_items is None else min(max_items, len(self._dq))
|
|
58
|
+
return [self._dq.popleft() for _ in range(n)]
|
|
59
|
+
|
|
60
|
+
def take_dropped(self) -> int:
|
|
61
|
+
"""Reset and return the dropped-event count since the last call."""
|
|
62
|
+
with self._lock:
|
|
63
|
+
d, self._dropped = self._dropped, 0
|
|
64
|
+
return d
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def dropped(self) -> int:
|
|
68
|
+
"""Peek the pending drop count without resetting it."""
|
|
69
|
+
with self._lock:
|
|
70
|
+
return self._dropped
|
|
71
|
+
|
|
72
|
+
def release_dropped(self, n: int) -> None:
|
|
73
|
+
"""Subtract n from the pending drop count once it has actually been
|
|
74
|
+
ACKNOWLEDGED by the server, rather than zeroing it the moment it's
|
|
75
|
+
read (take_dropped()'s behavior, which is what Flusher used to call
|
|
76
|
+
unconditionally BEFORE knowing whether the send would even succeed --
|
|
77
|
+
a failed/retried send silently threw the count away, exactly wrong
|
|
78
|
+
during a sustained-429 run where every attempt discarded it again).
|
|
79
|
+
"""
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._dropped = max(0, self._dropped - n)
|
|
82
|
+
|
|
83
|
+
def requeue(self, events: List[dict]) -> None:
|
|
84
|
+
"""Put a drained-but-unsent batch back, oldest-first, for a failed
|
|
85
|
+
send -- mirrors the TypeScript SDK's unshiftMany(). Without this,
|
|
86
|
+
drain() had already removed the events from the deque, and a raised
|
|
87
|
+
send() (network error, non-2xx) meant _flush_once()'s outer
|
|
88
|
+
try/except simply swallowed them: a real data-loss bug, not just a
|
|
89
|
+
counter bug. If the buffer filled back up while this batch was in
|
|
90
|
+
flight, the newest of what's being returned gives way (drop-oldest
|
|
91
|
+
semantics still hold; what's already in the buffer is newer)."""
|
|
92
|
+
with self._lock:
|
|
93
|
+
maxlen = self._dq.maxlen or 0
|
|
94
|
+
for ev in reversed(events):
|
|
95
|
+
if len(self._dq) >= maxlen:
|
|
96
|
+
self._dropped += 1
|
|
97
|
+
continue
|
|
98
|
+
self._dq.appendleft(ev)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Flusher:
|
|
102
|
+
"""Background thread: flush when len(buffer) >= batch_size OR every interval_s."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
buffer: RingBuffer,
|
|
107
|
+
send: Callable[[List[dict], int], None],
|
|
108
|
+
*,
|
|
109
|
+
interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
|
|
110
|
+
batch_size: int = DEFAULT_FLUSH_BATCH_SIZE,
|
|
111
|
+
) -> None:
|
|
112
|
+
self._buffer = buffer
|
|
113
|
+
self._send = send
|
|
114
|
+
self._interval_s = interval_s
|
|
115
|
+
self._batch_size = batch_size
|
|
116
|
+
self._wake = threading.Event()
|
|
117
|
+
self._stop = threading.Event()
|
|
118
|
+
self._thread = threading.Thread(target=self._run, name="tokenmeter-flusher", daemon=True)
|
|
119
|
+
self._atexit_registered = False
|
|
120
|
+
self._consecutive_429s = 0
|
|
121
|
+
self._warned_sustained_429 = False
|
|
122
|
+
|
|
123
|
+
def start(self) -> None:
|
|
124
|
+
self._thread.start()
|
|
125
|
+
if not self._atexit_registered:
|
|
126
|
+
atexit.register(self.flush_best_effort, deadline_s=2.0)
|
|
127
|
+
self._atexit_registered = True
|
|
128
|
+
|
|
129
|
+
def notify(self) -> None:
|
|
130
|
+
"""Called on every append; wakes the flusher early once batch_size is hit."""
|
|
131
|
+
if len(self._buffer) >= self._batch_size:
|
|
132
|
+
self._wake.set()
|
|
133
|
+
|
|
134
|
+
def _run(self) -> None:
|
|
135
|
+
while not self._stop.is_set():
|
|
136
|
+
self._wake.wait(timeout=self._interval_s)
|
|
137
|
+
self._wake.clear()
|
|
138
|
+
self._flush_once()
|
|
139
|
+
|
|
140
|
+
def _flush_once(self) -> None:
|
|
141
|
+
events = self._buffer.drain()
|
|
142
|
+
if not events:
|
|
143
|
+
return
|
|
144
|
+
# Peek, don't consume: the count is only "communicated" once the
|
|
145
|
+
# server actually acknowledges it. take_dropped() used to be called
|
|
146
|
+
# unconditionally here, BEFORE knowing whether send() would even
|
|
147
|
+
# succeed -- a failed send meant the drop count it just zeroed was
|
|
148
|
+
# gone, silently under-reporting exactly the signal DECISIONS.md §8
|
|
149
|
+
# relies on to make loss visible (worst during a sustained-429 run,
|
|
150
|
+
# where every retry attempt reset it again).
|
|
151
|
+
dropped = self._buffer.dropped
|
|
152
|
+
try:
|
|
153
|
+
self._send(events, dropped)
|
|
154
|
+
except Exception as exc: # SDK NEVER throws into host code -- swallow, log, move on.
|
|
155
|
+
# drain() already removed these from the buffer; without putting
|
|
156
|
+
# them back, a failed send silently discarded the whole batch --
|
|
157
|
+
# not just under-counted drops, an actual data-loss bug.
|
|
158
|
+
self._buffer.requeue(events)
|
|
159
|
+
self._note_failure(exc)
|
|
160
|
+
logger.debug("tokenmeter: flush failed", exc_info=True)
|
|
161
|
+
return
|
|
162
|
+
self._buffer.release_dropped(dropped)
|
|
163
|
+
self._consecutive_429s = 0
|
|
164
|
+
self._warned_sustained_429 = False
|
|
165
|
+
|
|
166
|
+
def _note_failure(self, exc: Exception) -> None:
|
|
167
|
+
"""DECISIONS.md §8: sustained 429s must be loud, not a silent retry
|
|
168
|
+
spin. Mirrors diagnose()'s "print + log, never raise" approach --
|
|
169
|
+
one warning per incident (not one per attempt), reset once traffic
|
|
170
|
+
stops being rate-limited."""
|
|
171
|
+
code = getattr(exc, "code", None)
|
|
172
|
+
if code == 429:
|
|
173
|
+
self._consecutive_429s += 1
|
|
174
|
+
else:
|
|
175
|
+
self._consecutive_429s = 0
|
|
176
|
+
self._warned_sustained_429 = False
|
|
177
|
+
return
|
|
178
|
+
if self._consecutive_429s >= CONSECUTIVE_429_WARN_THRESHOLD and not self._warned_sustained_429:
|
|
179
|
+
self._warned_sustained_429 = True
|
|
180
|
+
msg = (
|
|
181
|
+
f"tokenmeter: {self._consecutive_429s} consecutive batches rate-limited (HTTP 429). "
|
|
182
|
+
"Events are being retried, not dropped by the server, but this SDK's local buffer "
|
|
183
|
+
f"(maxsize={self._buffer.capacity}) can still overflow under sustained pressure -- "
|
|
184
|
+
"watch the dropped-event count. If this persists, the ingest key's plan quota is "
|
|
185
|
+
"genuinely too low for this traffic. Run tokenmeter.diagnose() for more detail."
|
|
186
|
+
)
|
|
187
|
+
logger.warning(msg)
|
|
188
|
+
print("[tokenmeter] WARNING: " + msg)
|
|
189
|
+
|
|
190
|
+
def flush_best_effort(self, deadline_s: float = 2.0) -> None:
|
|
191
|
+
"""Synchronous, best-effort, capped flush -- used by atexit and public flush()."""
|
|
192
|
+
deadline = time.monotonic() + deadline_s
|
|
193
|
+
try:
|
|
194
|
+
while len(self._buffer) > 0 and time.monotonic() < deadline:
|
|
195
|
+
self._flush_once()
|
|
196
|
+
except Exception:
|
|
197
|
+
logger.debug("tokenmeter: atexit flush failed", exc_info=True)
|
|
198
|
+
|
|
199
|
+
def stop(self) -> None:
|
|
200
|
+
self._stop.set()
|
|
201
|
+
self._wake.set()
|
tokenmeter/_client.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Global SDK state, set up once by tokenmeter.init()."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._buffer import DEFAULT_FLUSH_BATCH_SIZE, DEFAULT_FLUSH_INTERVAL_S, DEFAULT_MAXSIZE, Flusher, RingBuffer
|
|
8
|
+
from ._transport import Transport
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("tokenmeter")
|
|
11
|
+
|
|
12
|
+
DEFAULT_ENDPOINT = "https://ingest.timewastevc.com"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _State:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
ingest_key: str,
|
|
19
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
20
|
+
*,
|
|
21
|
+
buffer_size: int = DEFAULT_MAXSIZE,
|
|
22
|
+
flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
|
|
23
|
+
flush_batch_size: int = DEFAULT_FLUSH_BATCH_SIZE,
|
|
24
|
+
optimization: Optional[dict] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.optimization = dict(optimization) if isinstance(optimization, dict) else {}
|
|
27
|
+
self.transport = Transport(ingest_key, endpoint)
|
|
28
|
+
self.buffer = RingBuffer(buffer_size)
|
|
29
|
+
self.flusher = Flusher(
|
|
30
|
+
self.buffer,
|
|
31
|
+
self.transport.send,
|
|
32
|
+
interval_s=flush_interval_s,
|
|
33
|
+
batch_size=flush_batch_size,
|
|
34
|
+
)
|
|
35
|
+
self.flusher.start()
|
|
36
|
+
|
|
37
|
+
def emit(self, event: dict) -> None:
|
|
38
|
+
# SDK NEVER throws into host code and NEVER adds latency to the caller --
|
|
39
|
+
# this is a memory append, not I/O, so it's already sub-microsecond; the
|
|
40
|
+
# try/except is only for the pathological case of an unpicklable/huge event.
|
|
41
|
+
try:
|
|
42
|
+
self.buffer.append(event)
|
|
43
|
+
self.flusher.notify()
|
|
44
|
+
except Exception:
|
|
45
|
+
logger.debug("tokenmeter: emit failed", exc_info=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_state: Optional[_State] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def init(
|
|
52
|
+
ingest_key: str,
|
|
53
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
54
|
+
*,
|
|
55
|
+
router_url: Optional[str] = None,
|
|
56
|
+
governance_mode: str = "enforce",
|
|
57
|
+
governance_fail_mode: str = "closed",
|
|
58
|
+
optimization: Optional[dict] = None,
|
|
59
|
+
**kwargs,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""router_url enables tokenmeter.preflight()/governed() (governance before direct provider calls)."""
|
|
62
|
+
global _state
|
|
63
|
+
from . import _preflight
|
|
64
|
+
|
|
65
|
+
_preflight.configure(ingest_key, router_url, mode=governance_mode, fail_mode=governance_fail_mode)
|
|
66
|
+
_state = _State(ingest_key, endpoint, optimization=optimization, **kwargs)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_state() -> Optional[_State]:
|
|
70
|
+
return _state
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _reset_for_tests() -> None:
|
|
74
|
+
"""Test-only: drop global state so each test starts clean."""
|
|
75
|
+
global _state
|
|
76
|
+
if _state is not None:
|
|
77
|
+
_state.flusher.stop()
|
|
78
|
+
_state = None
|
tokenmeter/_context.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Trace/action context propagation (PRD §11).
|
|
2
|
+
|
|
3
|
+
contextvars.ContextVar survives asyncio task spawns (gather, create_task) and
|
|
4
|
+
thread-pool submission natively as long as the submitted callable captures the
|
|
5
|
+
context (contextvars.copy_context() / ThreadPoolExecutor + contextvars.copy_context
|
|
6
|
+
via `contextvars.copy_context().run(...)`, or Python's own default propagation into
|
|
7
|
+
`concurrent.futures.ThreadPoolExecutor.submit` -- which does NOT auto-propagate, so
|
|
8
|
+
callers crossing a real OS thread boundary must use `attach(current())` explicitly,
|
|
9
|
+
same escape hatch as a queue/serialize hop. This module provides that hatch.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextvars
|
|
14
|
+
import dataclasses
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ._ids import uuid7_str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass(frozen=True)
|
|
21
|
+
class TraceContext:
|
|
22
|
+
trace_id: str
|
|
23
|
+
span_id: str
|
|
24
|
+
parent_span_id: Optional[str] = None
|
|
25
|
+
action: Optional[str] = None
|
|
26
|
+
customer_ref: Optional[str] = None
|
|
27
|
+
feature: Optional[str] = None
|
|
28
|
+
team: Optional[str] = None
|
|
29
|
+
environment: Optional[str] = None
|
|
30
|
+
project: Optional[str] = None
|
|
31
|
+
member_ref: Optional[str] = None
|
|
32
|
+
optimization: Optional[dict] = None
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict:
|
|
35
|
+
d = dataclasses.asdict(self)
|
|
36
|
+
# A function (e.g. optimization["topic_classifier"]) cannot cross a queue/serialize hop; the receiving side
|
|
37
|
+
# falls back to the classifier configured in its own tokenmeter.init().
|
|
38
|
+
if isinstance(d.get("optimization"), dict):
|
|
39
|
+
d["optimization"] = {k: v for k, v in d["optimization"].items() if not callable(v)}
|
|
40
|
+
return d
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, d: dict) -> "TraceContext":
|
|
44
|
+
return cls(**d)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_current: "contextvars.ContextVar[Optional[TraceContext]]" = contextvars.ContextVar(
|
|
48
|
+
"tokenmeter_context", default=None
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def current() -> Optional[TraceContext]:
|
|
53
|
+
"""The active TraceContext, or None if no action() scope is open.
|
|
54
|
+
|
|
55
|
+
Serializable (dataclass of str fields) so it can cross a queue/serialize hop:
|
|
56
|
+
`payload = tokenmeter.current().to_dict()` ... `tokenmeter.attach(TraceContext.from_dict(payload))`.
|
|
57
|
+
"""
|
|
58
|
+
return _current.get()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def attach(ctx: Optional[TraceContext]) -> contextvars.Token:
|
|
62
|
+
"""Escape hatch for thread pools and queue/serialize hops (PRD §11).
|
|
63
|
+
|
|
64
|
+
Returns the reset Token so the caller can restore the prior value if it owns
|
|
65
|
+
the context's lifetime (rarely needed -- a fresh thread/task has nothing to
|
|
66
|
+
restore to).
|
|
67
|
+
"""
|
|
68
|
+
return _current.set(ctx)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _reset(token: contextvars.Token) -> None:
|
|
72
|
+
_current.reset(token)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class action:
|
|
76
|
+
"""Context manager. Nested calls form a span chain (parent_span_id chain)."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
name: str,
|
|
81
|
+
*,
|
|
82
|
+
customer_id: Optional[str] = None,
|
|
83
|
+
customer_ref: Optional[str] = None,
|
|
84
|
+
feature: Optional[str] = None,
|
|
85
|
+
team: Optional[str] = None,
|
|
86
|
+
environment: Optional[str] = None,
|
|
87
|
+
project: Optional[str] = None,
|
|
88
|
+
member_ref: Optional[str] = None,
|
|
89
|
+
optimization: Optional[dict] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.name = name
|
|
92
|
+
# accept either kwarg spelling used across the PRD text/examples
|
|
93
|
+
self.customer_ref = customer_ref or customer_id
|
|
94
|
+
self.feature = feature
|
|
95
|
+
self.team = team
|
|
96
|
+
self.environment = environment
|
|
97
|
+
self.project = project
|
|
98
|
+
self.member_ref = member_ref
|
|
99
|
+
self.optimization = dict(optimization) if isinstance(optimization, dict) else {}
|
|
100
|
+
self._token: Optional[contextvars.Token] = None
|
|
101
|
+
|
|
102
|
+
def __enter__(self) -> TraceContext:
|
|
103
|
+
parent = current()
|
|
104
|
+
ctx = TraceContext(
|
|
105
|
+
trace_id=parent.trace_id if parent else uuid7_str(),
|
|
106
|
+
span_id=uuid7_str(),
|
|
107
|
+
parent_span_id=parent.span_id if parent else None,
|
|
108
|
+
action=self.name,
|
|
109
|
+
customer_ref=self.customer_ref if self.customer_ref is not None else (parent.customer_ref if parent else None),
|
|
110
|
+
feature=self.feature if self.feature is not None else (parent.feature if parent else None),
|
|
111
|
+
team=self.team if self.team is not None else (parent.team if parent else None),
|
|
112
|
+
environment=self.environment if self.environment is not None else (parent.environment if parent else None),
|
|
113
|
+
project=self.project if self.project is not None else (parent.project if parent else None),
|
|
114
|
+
member_ref=self.member_ref if self.member_ref is not None else (parent.member_ref if parent else None),
|
|
115
|
+
optimization={**(parent.optimization or {} if parent else {}), **self.optimization},
|
|
116
|
+
)
|
|
117
|
+
self._token = attach(ctx)
|
|
118
|
+
return ctx
|
|
119
|
+
|
|
120
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
121
|
+
if self._token is not None:
|
|
122
|
+
_reset(self._token)
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
# async support: `async with tokenmeter.action(...):` works identically since
|
|
126
|
+
# entering/exiting is synchronous context-var manipulation, not I/O.
|
|
127
|
+
async def __aenter__(self) -> TraceContext:
|
|
128
|
+
return self.__enter__()
|
|
129
|
+
|
|
130
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
131
|
+
self.__exit__(exc_type, exc, tb)
|
tokenmeter/_diagnose.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""tokenmeter.diagnose() -- finding M4: a typo'd ingest key must never be silent.
|
|
2
|
+
|
|
3
|
+
Everywhere else the SDK swallows errors (that's the whole point of "never throws
|
|
4
|
+
into host code"). diagnose() is the one deliberate exception: it is called on
|
|
5
|
+
purpose, synchronously, specifically to make failures LOUD -- it prints a clear
|
|
6
|
+
verdict and returns a structured result, and it is the documented answer to
|
|
7
|
+
"why do I see zero data in my dashboard."
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import urllib.error
|
|
12
|
+
|
|
13
|
+
from ._client import DEFAULT_ENDPOINT, get_state
|
|
14
|
+
from ._transport import Transport
|
|
15
|
+
|
|
16
|
+
_STATUS_HINTS = {
|
|
17
|
+
401: "invalid_key: the ingest key is wrong, revoked, or malformed. Check tokenmeter.init(ingest_key=...).",
|
|
18
|
+
403: "key_revoked: this key (or its org) was revoked/purged. Issue a new key from the dashboard.",
|
|
19
|
+
413: "batch_too_large: unrelated to diagnose() itself -- your real traffic is sending oversized batches.",
|
|
20
|
+
429: "rate_limited: traffic looks abuse-shaped (well over the plan quota), not ordinary overage -- ordinary overage is accepted (DECISIONS.md §8). A single 429 is retried and not data loss; SUSTAINED 429s can be, since this SDK's local buffer is bounded -- watch for the sustained-429 warning this SDK logs.",
|
|
21
|
+
503: "unavailable: TokenMeter ingest is temporarily down. Events buffer locally and retry.",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def diagnose(ingest_key: str = None, endpoint: str = None) -> dict:
|
|
26
|
+
"""Synchronously pings the Ingest API and returns
|
|
27
|
+
{"ok": bool, "status": int|None, "message": str}. Never raises. Prints the
|
|
28
|
+
verdict too, because the whole point is to be found by a human staring at a
|
|
29
|
+
silent dashboard, not by someone who already knew to check a return value.
|
|
30
|
+
"""
|
|
31
|
+
state = get_state()
|
|
32
|
+
key = ingest_key or (state.transport.ingest_key if state else None)
|
|
33
|
+
url = endpoint or (state.transport.endpoint.rsplit("/v1/", 1)[0] if state else DEFAULT_ENDPOINT)
|
|
34
|
+
|
|
35
|
+
if not key:
|
|
36
|
+
result = {"ok": False, "status": None, "message": "no ingest key: call tokenmeter.init(ingest_key=...) first, or pass one to diagnose()."}
|
|
37
|
+
_report(result)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
pong = Transport(key, url).ping()
|
|
42
|
+
result = {"ok": True, "status": pong["status"], "message": f"tokenmeter: ingest key valid, endpoint reachable ({url})."}
|
|
43
|
+
except urllib.error.HTTPError as e:
|
|
44
|
+
hint = _STATUS_HINTS.get(e.code, f"unexpected HTTP {e.code}")
|
|
45
|
+
result = {"ok": False, "status": e.code, "message": f"tokenmeter: {hint}"}
|
|
46
|
+
except urllib.error.URLError as e:
|
|
47
|
+
result = {"ok": False, "status": None, "message": f"tokenmeter: could not reach {url}: {e.reason}"}
|
|
48
|
+
except Exception as e: # still must never raise into host code
|
|
49
|
+
result = {"ok": False, "status": None, "message": f"tokenmeter: diagnose() failed unexpectedly: {e!r}"}
|
|
50
|
+
|
|
51
|
+
_report(result)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _report(result: dict) -> None:
|
|
56
|
+
prefix = "[tokenmeter diagnose] OK: " if result["ok"] else "[tokenmeter diagnose] FAILED: "
|
|
57
|
+
print(prefix + result["message"])
|
tokenmeter/_ids.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""UUIDv7 (RFC 9562) event ids: time-ordered, client-generated, dedup key upstream."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def uuid7() -> uuid.UUID:
|
|
10
|
+
"""48-bit unix ms timestamp + version/variant bits + 74 random bits."""
|
|
11
|
+
unix_ts_ms = int(time.time() * 1000) & 0xFFFFFFFFFFFF # 48 bits
|
|
12
|
+
rand = os.urandom(10) # 12 bits rand_a + 62 bits rand_b = 74 bits, rounded up to 10 bytes
|
|
13
|
+
|
|
14
|
+
rand_a = int.from_bytes(rand[0:2], "big") & 0x0FFF # 12 bits
|
|
15
|
+
rand_b = int.from_bytes(rand[2:10], "big") & 0x3FFFFFFFFFFFFFFF # 62 bits
|
|
16
|
+
|
|
17
|
+
b = bytearray(16)
|
|
18
|
+
b[0:6] = unix_ts_ms.to_bytes(6, "big")
|
|
19
|
+
b[6] = 0x70 | (rand_a >> 8) # version 7 in top nibble
|
|
20
|
+
b[7] = rand_a & 0xFF
|
|
21
|
+
b[8] = 0x80 | (rand_b >> 56) & 0x3F # variant bits 10
|
|
22
|
+
b[9:16] = ((rand_b >> 0) & 0xFFFFFFFFFFFFFF).to_bytes(7, "big")
|
|
23
|
+
return uuid.UUID(bytes=bytes(b))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def uuid7_str() -> str:
|
|
27
|
+
return str(uuid7())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def demo() -> None:
|
|
31
|
+
ids = [uuid7() for _ in range(1000)]
|
|
32
|
+
strs = [str(i) for i in ids]
|
|
33
|
+
assert len(set(strs)) == 1000, "collision in 1000 draws"
|
|
34
|
+
for i in ids:
|
|
35
|
+
assert i.version == 7
|
|
36
|
+
# Timestamp prefix (first 12 hex chars = 48-bit ms) must be non-decreasing across
|
|
37
|
+
# calls in issuance order -- the property that makes these dedup/sort-friendly.
|
|
38
|
+
# (Random tail bits are NOT required to sort within the same millisecond.)
|
|
39
|
+
ts_prefixes = [s.replace("-", "")[:12] for s in strs]
|
|
40
|
+
assert ts_prefixes == sorted(ts_prefixes), "uuid7 timestamp prefix must be time-ordered"
|
|
41
|
+
print("uuid7 demo: ok")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
demo()
|