metergraph 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.
- metergraph/__init__.py +219 -0
- metergraph/_capture.py +1146 -0
- metergraph/_config.py +144 -0
- metergraph/_context.py +136 -0
- metergraph/_template.py +58 -0
- metergraph/_track.py +61 -0
- metergraph/_transport.py +171 -0
- metergraph/_version.py +11 -0
- metergraph-0.1.0.dist-info/METADATA +88 -0
- metergraph-0.1.0.dist-info/RECORD +12 -0
- metergraph-0.1.0.dist-info/WHEEL +5 -0
- metergraph-0.1.0.dist-info/top_level.txt +1 -0
metergraph/_config.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""ETag-aware, fail-open canary configuration polling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("metergraph")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _bucket(seed: str) -> float:
|
|
19
|
+
"""FNV-1a/64 over UTF-8; kept byte-identical to the TypeScript SDK."""
|
|
20
|
+
value = 0xCBF29CE484222325
|
|
21
|
+
for byte in seed.encode():
|
|
22
|
+
value ^= byte
|
|
23
|
+
value = (value * 0x100000001B3) & 0xFFFF_FFFF_FFFF_FFFF
|
|
24
|
+
return value / 2**64 * 100
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def choose_model(
|
|
28
|
+
route: str,
|
|
29
|
+
default: str,
|
|
30
|
+
session_key: str | None,
|
|
31
|
+
route_config: Mapping[str, Any] | None,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Deterministically assign a session to a canary arm."""
|
|
34
|
+
if not route_config or route_config.get("enabled", True) is False:
|
|
35
|
+
return default
|
|
36
|
+
incumbent = str(route_config.get("incumbent_model") or default)
|
|
37
|
+
challenger = route_config.get("challenger_model") or route_config.get("model")
|
|
38
|
+
if not challenger or not session_key:
|
|
39
|
+
return incumbent
|
|
40
|
+
try:
|
|
41
|
+
percent = float(
|
|
42
|
+
route_config.get(
|
|
43
|
+
"traffic_percent",
|
|
44
|
+
route_config.get("percentage", route_config.get("allocation", 0)),
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
return incumbent
|
|
49
|
+
percent = max(0.0, min(percent, 100.0))
|
|
50
|
+
seed = ":".join(
|
|
51
|
+
(
|
|
52
|
+
route,
|
|
53
|
+
str(route_config.get("version", "")),
|
|
54
|
+
str(route_config.get("salt", "")),
|
|
55
|
+
str(session_key),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return str(challenger) if _bucket(seed) < percent else incumbent
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConfigPoller:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
token: str,
|
|
65
|
+
base_url: str,
|
|
66
|
+
*,
|
|
67
|
+
poll_seconds: float = 30.0,
|
|
68
|
+
hard_ttl_seconds: float = 120.0,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._token = token
|
|
71
|
+
self._url = f"{base_url.rstrip('/')}/v1/config"
|
|
72
|
+
self._poll_seconds = max(1.0, poll_seconds)
|
|
73
|
+
self._hard_ttl = max(self._poll_seconds, hard_ttl_seconds)
|
|
74
|
+
self._etag: str | None = None
|
|
75
|
+
self._routes: dict[str, dict[str, Any]] = {}
|
|
76
|
+
self._last_success = 0.0
|
|
77
|
+
self._lock = threading.Lock()
|
|
78
|
+
self._stop = threading.Event()
|
|
79
|
+
self._thread = threading.Thread(
|
|
80
|
+
target=self._run, name="metergraph-config", daemon=True
|
|
81
|
+
)
|
|
82
|
+
self._thread.start()
|
|
83
|
+
|
|
84
|
+
def _run(self) -> None:
|
|
85
|
+
while not self._stop.is_set():
|
|
86
|
+
self.poll_once()
|
|
87
|
+
self._stop.wait(self._poll_seconds)
|
|
88
|
+
|
|
89
|
+
def poll_once(self) -> bool:
|
|
90
|
+
headers = {
|
|
91
|
+
"Authorization": f"Bearer {self._token}",
|
|
92
|
+
"Accept": "application/json",
|
|
93
|
+
}
|
|
94
|
+
if self._etag:
|
|
95
|
+
headers["If-None-Match"] = self._etag
|
|
96
|
+
request = urllib.request.Request(self._url, headers=headers)
|
|
97
|
+
try:
|
|
98
|
+
with urllib.request.urlopen(request, timeout=5) as response:
|
|
99
|
+
if response.status == 304:
|
|
100
|
+
with self._lock:
|
|
101
|
+
self._last_success = time.monotonic()
|
|
102
|
+
return True
|
|
103
|
+
doc = json.loads(response.read())
|
|
104
|
+
routes = doc.get("routes", {}) if isinstance(doc, dict) else {}
|
|
105
|
+
if not isinstance(routes, dict):
|
|
106
|
+
return False
|
|
107
|
+
with self._lock:
|
|
108
|
+
self._routes = {
|
|
109
|
+
str(k): dict(v)
|
|
110
|
+
for k, v in routes.items()
|
|
111
|
+
if isinstance(v, dict)
|
|
112
|
+
}
|
|
113
|
+
self._etag = response.headers.get("ETag")
|
|
114
|
+
self._last_success = time.monotonic()
|
|
115
|
+
return True
|
|
116
|
+
except urllib.error.HTTPError as exc:
|
|
117
|
+
# urllib raises for 304 even though it is a successful revalidation.
|
|
118
|
+
if exc.code == 304:
|
|
119
|
+
with self._lock:
|
|
120
|
+
self._last_success = time.monotonic()
|
|
121
|
+
return True
|
|
122
|
+
if exc.code in (401, 403):
|
|
123
|
+
log.warning(
|
|
124
|
+
"Metergraph config authentication failed; using default models"
|
|
125
|
+
)
|
|
126
|
+
self._stop.set()
|
|
127
|
+
return False
|
|
128
|
+
except Exception:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def model_for(self, route: str, default: str, session_key: str | None) -> str:
|
|
132
|
+
with self._lock:
|
|
133
|
+
if (
|
|
134
|
+
not self._last_success
|
|
135
|
+
or time.monotonic() - self._last_success > self._hard_ttl
|
|
136
|
+
):
|
|
137
|
+
return default
|
|
138
|
+
config = self._routes.get(route)
|
|
139
|
+
return choose_model(route, default, session_key, config)
|
|
140
|
+
|
|
141
|
+
def stop(self) -> None:
|
|
142
|
+
self._stop.set()
|
|
143
|
+
if threading.current_thread() is not self._thread:
|
|
144
|
+
self._thread.join(timeout=1)
|
metergraph/_context.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Ambient route/session metadata, safe across async call trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
from concurrent.futures import Executor
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from typing import Any, Callable, Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CaptureContext:
|
|
15
|
+
route: str | None = None
|
|
16
|
+
session_id: str | None = None
|
|
17
|
+
tags: Mapping[str, str] = field(default_factory=dict)
|
|
18
|
+
unit_name: str | None = None
|
|
19
|
+
unit_count: float | None = None
|
|
20
|
+
capture_text: bool | None = None
|
|
21
|
+
func_name: str | None = None
|
|
22
|
+
func_module: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_current: contextvars.ContextVar[CaptureContext] = contextvars.ContextVar(
|
|
26
|
+
"metergraph_context", default=CaptureContext()
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def snapshot() -> CaptureContext:
|
|
31
|
+
return _current.get()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class route:
|
|
35
|
+
"""Route context manager and sync/async decorator."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
name: str,
|
|
40
|
+
*,
|
|
41
|
+
unit: str | None = None,
|
|
42
|
+
unit_count: float | None = None,
|
|
43
|
+
tags: Mapping[str, Any] | None = None,
|
|
44
|
+
capture_text: bool | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.name = str(name)
|
|
47
|
+
self.unit = str(unit) if unit is not None else None
|
|
48
|
+
self.unit_count = (
|
|
49
|
+
float(unit_count) if unit_count is not None else (1.0 if unit else None)
|
|
50
|
+
)
|
|
51
|
+
self.tags = {str(k): str(v) for k, v in (tags or {}).items()}
|
|
52
|
+
self.capture_text = (
|
|
53
|
+
bool(capture_text) if capture_text is not None else None
|
|
54
|
+
)
|
|
55
|
+
self._token: contextvars.Token[CaptureContext] | None = None
|
|
56
|
+
|
|
57
|
+
def __enter__(self) -> "route":
|
|
58
|
+
current = snapshot()
|
|
59
|
+
merged = {**current.tags, **self.tags}
|
|
60
|
+
self._token = _current.set(
|
|
61
|
+
replace(
|
|
62
|
+
current,
|
|
63
|
+
route=self.name,
|
|
64
|
+
tags=merged,
|
|
65
|
+
unit_name=self.unit if self.unit is not None else current.unit_name,
|
|
66
|
+
unit_count=self.unit_count
|
|
67
|
+
if self.unit is not None
|
|
68
|
+
else current.unit_count,
|
|
69
|
+
capture_text=self.capture_text
|
|
70
|
+
if self.capture_text is not None
|
|
71
|
+
else current.capture_text,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
77
|
+
if self._token is not None:
|
|
78
|
+
_current.reset(self._token)
|
|
79
|
+
self._token = None
|
|
80
|
+
|
|
81
|
+
def __call__(self, fn: Callable):
|
|
82
|
+
if inspect.iscoroutinefunction(fn):
|
|
83
|
+
|
|
84
|
+
@functools.wraps(fn)
|
|
85
|
+
async def async_wrapped(*args, **kwargs):
|
|
86
|
+
with type(self)(
|
|
87
|
+
self.name,
|
|
88
|
+
unit=self.unit,
|
|
89
|
+
unit_count=self.unit_count,
|
|
90
|
+
tags=self.tags,
|
|
91
|
+
capture_text=self.capture_text,
|
|
92
|
+
):
|
|
93
|
+
return await fn(*args, **kwargs)
|
|
94
|
+
|
|
95
|
+
return async_wrapped
|
|
96
|
+
|
|
97
|
+
@functools.wraps(fn)
|
|
98
|
+
def wrapped(*args, **kwargs):
|
|
99
|
+
with type(self)(
|
|
100
|
+
self.name,
|
|
101
|
+
unit=self.unit,
|
|
102
|
+
unit_count=self.unit_count,
|
|
103
|
+
tags=self.tags,
|
|
104
|
+
capture_text=self.capture_text,
|
|
105
|
+
):
|
|
106
|
+
return fn(*args, **kwargs)
|
|
107
|
+
|
|
108
|
+
return wrapped
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def set_session(session_id: str | None) -> None:
|
|
112
|
+
_current.set(
|
|
113
|
+
replace(snapshot(), session_id=str(session_id) if session_id else None)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def set_tags(**tags: Any) -> None:
|
|
118
|
+
current = snapshot()
|
|
119
|
+
merged = {**current.tags, **{str(k): str(v) for k, v in tags.items()}}
|
|
120
|
+
_current.set(replace(current, tags=merged))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def wrap_executor(executor: Executor) -> Executor:
|
|
124
|
+
"""Propagate the current context into executor submissions."""
|
|
125
|
+
if getattr(executor, "__metergraph__", False):
|
|
126
|
+
return executor
|
|
127
|
+
original_submit = executor.submit
|
|
128
|
+
|
|
129
|
+
@functools.wraps(original_submit)
|
|
130
|
+
def submit(fn, /, *args, **kwargs):
|
|
131
|
+
ctx = contextvars.copy_context()
|
|
132
|
+
return original_submit(ctx.run, fn, *args, **kwargs)
|
|
133
|
+
|
|
134
|
+
executor.submit = submit # type: ignore[method-assign]
|
|
135
|
+
executor.__metergraph__ = True # type: ignore[attr-defined]
|
|
136
|
+
return executor
|
metergraph/_template.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Stable, privacy-conscious request fingerprinting for untagged traffic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_UUID = re.compile(r"\b[0-9a-f]{8}-[0-9a-f-]{27,}\b", re.I)
|
|
13
|
+
_EMAIL = re.compile(r"\b[^\s@]+@[^\s@]+\.[^\s@]+\b")
|
|
14
|
+
_URL = re.compile(r"\bhttps?://\S+")
|
|
15
|
+
_NUMBER = re.compile(r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?(?![A-Za-z])")
|
|
16
|
+
_LONG_TOKEN = re.compile(r"\b[A-Za-z0-9_-]{24,}\b")
|
|
17
|
+
_SENSITIVE_KEYS = {"api_key", "apikey", "authorization", "headers", "token", "secret"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_text(value: str) -> str:
|
|
21
|
+
value = _UUID.sub("<uuid>", value)
|
|
22
|
+
value = _EMAIL.sub("<email>", value)
|
|
23
|
+
value = _URL.sub("<url>", value)
|
|
24
|
+
value = _LONG_TOKEN.sub("<token>", value)
|
|
25
|
+
value = _NUMBER.sub("<n>", value)
|
|
26
|
+
return " ".join(value.split())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def scrub(value: Any) -> Any:
|
|
30
|
+
if isinstance(value, Mapping):
|
|
31
|
+
return {
|
|
32
|
+
str(k): scrub(v)
|
|
33
|
+
for k, v in value.items()
|
|
34
|
+
if str(k).lower() not in _SENSITIVE_KEYS
|
|
35
|
+
}
|
|
36
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
37
|
+
return [scrub(item) for item in value]
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
return value
|
|
40
|
+
if isinstance(value, (int, float, bool)) or value is None:
|
|
41
|
+
return value
|
|
42
|
+
return repr(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def template_hash(request: Mapping[str, Any]) -> str:
|
|
46
|
+
def skeleton(value: Any) -> Any:
|
|
47
|
+
if isinstance(value, Mapping):
|
|
48
|
+
return {str(k): skeleton(v) for k, v in sorted(value.items())}
|
|
49
|
+
if isinstance(value, list):
|
|
50
|
+
return [skeleton(item) for item in value]
|
|
51
|
+
if isinstance(value, str):
|
|
52
|
+
return _normalize_text(value)
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
encoded = json.dumps(
|
|
56
|
+
skeleton(scrub(request)), sort_keys=True, separators=(",", ":")
|
|
57
|
+
)
|
|
58
|
+
return hashlib.sha256(encoded.encode()).hexdigest()
|
metergraph/_track.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Explicit call-site attribution, overriding the default stack-walk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from ._context import CaptureContext, _current, snapshot
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class track:
|
|
15
|
+
"""Attribution context manager and sync/async decorator."""
|
|
16
|
+
|
|
17
|
+
def __new__(
|
|
18
|
+
cls, name: Callable | str | None = None, *, module: str | None = None
|
|
19
|
+
):
|
|
20
|
+
if callable(name):
|
|
21
|
+
instance = super().__new__(cls)
|
|
22
|
+
instance.__init__(module=module)
|
|
23
|
+
return instance(name)
|
|
24
|
+
return super().__new__(cls)
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self, name: str | None = None, *, module: str | None = None
|
|
28
|
+
) -> None:
|
|
29
|
+
self.name = str(name) if name is not None else None
|
|
30
|
+
self.module = str(module) if module is not None else None
|
|
31
|
+
self._token: contextvars.Token[CaptureContext] | None = None
|
|
32
|
+
|
|
33
|
+
def __enter__(self) -> "track":
|
|
34
|
+
self._token = _current.set(
|
|
35
|
+
replace(snapshot(), func_name=self.name, func_module=self.module)
|
|
36
|
+
)
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
40
|
+
if self._token is not None:
|
|
41
|
+
_current.reset(self._token)
|
|
42
|
+
self._token = None
|
|
43
|
+
|
|
44
|
+
def __call__(self, fn: Callable):
|
|
45
|
+
name = self.name or f"{fn.__module__}:{fn.__qualname__}"
|
|
46
|
+
module = self.module or fn.__module__
|
|
47
|
+
if inspect.iscoroutinefunction(fn):
|
|
48
|
+
|
|
49
|
+
@functools.wraps(fn)
|
|
50
|
+
async def async_wrapped(*args, **kwargs):
|
|
51
|
+
with type(self)(name, module=module):
|
|
52
|
+
return await fn(*args, **kwargs)
|
|
53
|
+
|
|
54
|
+
return async_wrapped
|
|
55
|
+
|
|
56
|
+
@functools.wraps(fn)
|
|
57
|
+
def wrapped(*args, **kwargs):
|
|
58
|
+
with type(self)(name, module=module):
|
|
59
|
+
return fn(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
return wrapped
|
metergraph/_transport.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Bounded, fire-and-forget HTTP transport using only the stdlib."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ._version import SDK_VERSION
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("metergraph")
|
|
20
|
+
MAX_BATCH_BYTES = 512 * 1024
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Writer:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
token: str,
|
|
27
|
+
base_url: str,
|
|
28
|
+
*,
|
|
29
|
+
queue_size: int = 2000,
|
|
30
|
+
batch_size: int = 100,
|
|
31
|
+
flush_seconds: float = 5.0,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._token = token
|
|
34
|
+
self._url = f"{base_url.rstrip('/')}/v1/ingest"
|
|
35
|
+
self._queue_size = max(1, queue_size)
|
|
36
|
+
self._batch_size = max(1, min(batch_size, 1000))
|
|
37
|
+
self._flush_seconds = max(0.05, flush_seconds)
|
|
38
|
+
self._queue: queue.Queue[dict[str, Any]] = queue.Queue(self._queue_size)
|
|
39
|
+
self._stop = threading.Event()
|
|
40
|
+
self._flush_now = threading.Event()
|
|
41
|
+
self._fatal = False
|
|
42
|
+
self._dropped = 0
|
|
43
|
+
self._errors = 0
|
|
44
|
+
self._backoff = 1.0
|
|
45
|
+
self._retry_at = 0.0
|
|
46
|
+
self._thread = self._new_thread()
|
|
47
|
+
self._thread.start()
|
|
48
|
+
if hasattr(os, "register_at_fork"):
|
|
49
|
+
os.register_at_fork(after_in_child=self._after_fork)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def dropped(self) -> int:
|
|
53
|
+
return self._dropped
|
|
54
|
+
|
|
55
|
+
def _new_thread(self) -> threading.Thread:
|
|
56
|
+
return threading.Thread(target=self._run, name="metergraph-writer", daemon=True)
|
|
57
|
+
|
|
58
|
+
def _after_fork(self) -> None:
|
|
59
|
+
# Inherited Queue locks and threads are not safe in the child. Pending
|
|
60
|
+
# parent rows stay with the parent; the child starts a clean writer.
|
|
61
|
+
self._queue = queue.Queue(self._queue_size)
|
|
62
|
+
self._stop = threading.Event()
|
|
63
|
+
self._flush_now = threading.Event()
|
|
64
|
+
self._thread = self._new_thread()
|
|
65
|
+
self._thread.start()
|
|
66
|
+
|
|
67
|
+
def enqueue(self, row: dict[str, Any]) -> bool:
|
|
68
|
+
if self._fatal or self._stop.is_set():
|
|
69
|
+
self._dropped += 1
|
|
70
|
+
return False
|
|
71
|
+
try:
|
|
72
|
+
self._queue.put_nowait(row)
|
|
73
|
+
return True
|
|
74
|
+
except queue.Full:
|
|
75
|
+
self._dropped += 1
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def _run(self) -> None:
|
|
79
|
+
while not self._stop.is_set() or not self._queue.empty():
|
|
80
|
+
try:
|
|
81
|
+
first = self._queue.get(timeout=min(self._flush_seconds, 0.1))
|
|
82
|
+
except queue.Empty:
|
|
83
|
+
continue
|
|
84
|
+
batch = [first]
|
|
85
|
+
deadline = time.monotonic() + self._flush_seconds
|
|
86
|
+
while len(batch) < self._batch_size:
|
|
87
|
+
if self._flush_now.is_set():
|
|
88
|
+
break
|
|
89
|
+
try:
|
|
90
|
+
batch.append(self._queue.get_nowait())
|
|
91
|
+
except queue.Empty:
|
|
92
|
+
remaining = deadline - time.monotonic()
|
|
93
|
+
if remaining <= 0 or self._stop.is_set():
|
|
94
|
+
break
|
|
95
|
+
try:
|
|
96
|
+
batch.append(self._queue.get(timeout=min(remaining, 0.05)))
|
|
97
|
+
except queue.Empty:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
self._deliver(batch)
|
|
101
|
+
finally:
|
|
102
|
+
self._flush_now.clear()
|
|
103
|
+
for _ in batch:
|
|
104
|
+
self._queue.task_done()
|
|
105
|
+
|
|
106
|
+
def _deliver(self, rows: list[dict[str, Any]]) -> bool:
|
|
107
|
+
if self._fatal or time.monotonic() < self._retry_at:
|
|
108
|
+
self._dropped += len(rows)
|
|
109
|
+
return False
|
|
110
|
+
meta = {"dropped": self._dropped, "transport_errors": self._errors}
|
|
111
|
+
body = json.dumps(
|
|
112
|
+
{"schema_version": 1, "rows": rows, "meta": meta},
|
|
113
|
+
separators=(",", ":"),
|
|
114
|
+
).encode()
|
|
115
|
+
headers = {
|
|
116
|
+
"Authorization": f"Bearer {self._token}",
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"User-Agent": f"metergraph-python/{SDK_VERSION}",
|
|
119
|
+
}
|
|
120
|
+
if len(body) > 32 * 1024:
|
|
121
|
+
body = gzip.compress(body)
|
|
122
|
+
headers["Content-Encoding"] = "gzip"
|
|
123
|
+
if len(body) > MAX_BATCH_BYTES:
|
|
124
|
+
if len(rows) == 1:
|
|
125
|
+
self._dropped += 1
|
|
126
|
+
return False
|
|
127
|
+
midpoint = len(rows) // 2
|
|
128
|
+
left = self._deliver(rows[:midpoint])
|
|
129
|
+
right = self._deliver(rows[midpoint:])
|
|
130
|
+
return left and right
|
|
131
|
+
request = urllib.request.Request(
|
|
132
|
+
self._url, data=body, headers=headers, method="POST"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(request, timeout=5) as response:
|
|
136
|
+
if response.status != 202:
|
|
137
|
+
raise OSError(f"unexpected ingest status {response.status}")
|
|
138
|
+
self._backoff = 1.0
|
|
139
|
+
self._retry_at = 0.0
|
|
140
|
+
return True
|
|
141
|
+
except urllib.error.HTTPError as exc:
|
|
142
|
+
if exc.code in (401, 403):
|
|
143
|
+
self._fatal = True
|
|
144
|
+
log.warning(
|
|
145
|
+
"Metergraph authentication failed; capture disabled for this process"
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
self._failed(len(rows))
|
|
149
|
+
return False
|
|
150
|
+
except Exception:
|
|
151
|
+
self._failed(len(rows))
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def _failed(self, count: int) -> None:
|
|
155
|
+
self._errors += 1
|
|
156
|
+
self._dropped += count
|
|
157
|
+
self._retry_at = time.monotonic() + self._backoff
|
|
158
|
+
self._backoff = min(self._backoff * 2, 60.0)
|
|
159
|
+
|
|
160
|
+
def flush(self, timeout: float = 3.0) -> bool:
|
|
161
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
162
|
+
self._flush_now.set()
|
|
163
|
+
while self._queue.unfinished_tasks and time.monotonic() < deadline:
|
|
164
|
+
time.sleep(0.01)
|
|
165
|
+
return self._queue.unfinished_tasks == 0
|
|
166
|
+
|
|
167
|
+
def shutdown(self) -> None:
|
|
168
|
+
self.flush(3.0)
|
|
169
|
+
self._stop.set()
|
|
170
|
+
if threading.current_thread() is not self._thread:
|
|
171
|
+
self._thread.join(timeout=max(1.0, self._flush_seconds + 0.5))
|
metergraph/_version.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metergraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fire-and-forget LLM spend capture for Metergraph
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
10
|
+
|
|
11
|
+
# metergraph (Python)
|
|
12
|
+
|
|
13
|
+
Zero-runtime-dependency capture for OpenAI, Anthropic, and Gemini clients.
|
|
14
|
+
`wrap()` initializes capture from the environment, so setup is one line per
|
|
15
|
+
client; call `metergraph.init(...)` before the first `wrap()` only to pass
|
|
16
|
+
options in code.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import metergraph
|
|
20
|
+
from openai import OpenAI
|
|
21
|
+
|
|
22
|
+
# Anthropic() and google-genai's genai.Client() wrap the same way.
|
|
23
|
+
client = metergraph.wrap(OpenAI())
|
|
24
|
+
metergraph.set_session("ticket-123")
|
|
25
|
+
|
|
26
|
+
with metergraph.route("ticket-classifier", unit="answer", capture_text=True):
|
|
27
|
+
model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
|
|
28
|
+
client.chat.completions.create(model=model, messages=[...])
|
|
29
|
+
|
|
30
|
+
# Emit this after the user-visible task resolves. It shares the bounded async
|
|
31
|
+
# transport and contains no prompt or output content.
|
|
32
|
+
metergraph.record_outcome(
|
|
33
|
+
"ticket-classifier",
|
|
34
|
+
model=model,
|
|
35
|
+
task_completed=True,
|
|
36
|
+
feedback_score=1,
|
|
37
|
+
turns_to_resolution=2,
|
|
38
|
+
escalated=False,
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Configuration:
|
|
43
|
+
|
|
44
|
+
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
45
|
+
- `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
|
|
46
|
+
- `METERGRAPH_CAPTURE_TEXT=1` — opt in to content capture globally; default is metadata-only
|
|
47
|
+
- `METERGRAPH_DISABLED=1` — process kill switch
|
|
48
|
+
- `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
|
|
49
|
+
|
|
50
|
+
Delivery is bounded and off the request path. Queue overflow or a collector
|
|
51
|
+
outage drops capture and increments internal counters; it never changes the
|
|
52
|
+
provider call. Each wire batch is bounded to 512 KiB after optional gzip.
|
|
53
|
+
`capture_text=True` or `False` on `route()` overrides the global content policy
|
|
54
|
+
for that route. Config reads are ETag-aware and fail open to the default model.
|
|
55
|
+
`record_outcome` requires a stable session ID and the model actually used so a
|
|
56
|
+
session-sticky canary can compare task completion and optional feedback,
|
|
57
|
+
turn-count, escalation, abandonment, edit-distance, and regeneration signals.
|
|
58
|
+
|
|
59
|
+
OpenAI Batch API output JSONL is captured per inference when a wrapped
|
|
60
|
+
`client.files.content()` / `retrieve_content()` result is read. Anthropic
|
|
61
|
+
message batches are captured per inference while iterating a wrapped
|
|
62
|
+
`client.messages.batches.results()` result. Run result consumption inside a
|
|
63
|
+
`route()` context so the asynchronous batch retains its product route. Batch
|
|
64
|
+
rows carry real per-result usage and the batch pricing flag; job-management
|
|
65
|
+
polls themselves are not miscounted as model calls.
|
|
66
|
+
|
|
67
|
+
## Set up with an AI coding agent
|
|
68
|
+
|
|
69
|
+
Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
|
|
70
|
+
codebase you want instrumented:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
Instrument this codebase's LLM API costs with the `metergraph` PyPI package
|
|
74
|
+
(https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
|
|
75
|
+
then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
|
|
76
|
+
genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
|
|
77
|
+
wrap() returns the same client and initializes itself from the environment:
|
|
78
|
+
METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
|
|
79
|
+
METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
|
|
80
|
+
.env.example, and never commit a real token. Capture is metadata-only (tokens,
|
|
81
|
+
latency, model, no prompt/completion content) and fail-open, so do not change
|
|
82
|
+
call sites, arguments, or error handling; sync, async, and streaming work
|
|
83
|
+
unchanged. Attribution to the calling function is automatic; optionally pin
|
|
84
|
+
stable names on key LLM-calling functions with @metergraph.track. On
|
|
85
|
+
serverless, call metergraph.flush() before the handler returns. When done,
|
|
86
|
+
list every client you wrapped and flag LLM calls made outside the official
|
|
87
|
+
openai / anthropic / google-genai SDKs, since those are not captured.
|
|
88
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
metergraph/__init__.py,sha256=S7r5T2d92vW16oFXZe1snYzVbWcRny__sTIpxogZHs0,7242
|
|
2
|
+
metergraph/_capture.py,sha256=TLl45sI_7quu5KuJrqb-XH8GELthDjBN5t4u-TTksFQ,38265
|
|
3
|
+
metergraph/_config.py,sha256=6esfubFNAl-Sd9TGlI3WuDCA9iHZFLZfAYqN240pb-8,4820
|
|
4
|
+
metergraph/_context.py,sha256=csWDvHJD6jZbyPv_eRo6SMTl2AQr66tG0u7obbtnYOk,4182
|
|
5
|
+
metergraph/_template.py,sha256=KD5ClwacgrkBnXy7eoOECsZkOduGtiMHuDtrxE6lG7I,1921
|
|
6
|
+
metergraph/_track.py,sha256=kquEubKHOtjRlvvREooPebz0q-1NzXvRJv_w1NAZ7_w,1892
|
|
7
|
+
metergraph/_transport.py,sha256=KVx1ulAwY8chBI_TrPbflVw71idYXXoj3ro4THt6bQU,5921
|
|
8
|
+
metergraph/_version.py,sha256=M4KyFTC7UfwqnN6Sz9tqRIVDCN8lqnWhWmbAtJm4LR8,239
|
|
9
|
+
metergraph-0.1.0.dist-info/METADATA,sha256=cobBY3Xq9hMbLPtqeZxwKUodprtbBn9PsZgKBoBewrw,4022
|
|
10
|
+
metergraph-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
metergraph-0.1.0.dist-info/top_level.txt,sha256=g-B1703jmqYqbs8HfEd41NaGufAL-e95rhe2rU_iyzc,11
|
|
12
|
+
metergraph-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metergraph
|