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/__init__.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Public Metergraph Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from ._capture import Options, Runtime, set_runtime
|
|
13
|
+
from ._capture import wrap as _wrap
|
|
14
|
+
from ._config import ConfigPoller
|
|
15
|
+
from ._context import route, set_session, set_tags, snapshot, wrap_executor
|
|
16
|
+
from ._track import track
|
|
17
|
+
from ._transport import Writer
|
|
18
|
+
from ._version import SDK_VERSION
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__version__ = SDK_VERSION
|
|
22
|
+
DEFAULT_INGEST_URL = "https://d2xus7mp8zdv6t.cloudfront.net"
|
|
23
|
+
log = logging.getLogger("metergraph")
|
|
24
|
+
_writer: Writer | None = None
|
|
25
|
+
_config: ConfigPoller | None = None
|
|
26
|
+
_initialized = False
|
|
27
|
+
_warned_no_token = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
31
|
+
value = os.getenv(name)
|
|
32
|
+
if value is None:
|
|
33
|
+
return default
|
|
34
|
+
return value.lower() not in {"0", "false", "no", "off"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def init(
|
|
38
|
+
*,
|
|
39
|
+
token: str | None = None,
|
|
40
|
+
ingest_url: str | None = None,
|
|
41
|
+
capture_text: bool | None = None,
|
|
42
|
+
redact: Callable[[str, str], str] | None = None,
|
|
43
|
+
app_root: str | None = None,
|
|
44
|
+
skip_frames: list[str] | None = None,
|
|
45
|
+
environment: str | None = None,
|
|
46
|
+
disabled: bool | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Initialize capture. This function is idempotent and never raises."""
|
|
49
|
+
global _initialized, _warned_no_token, _writer, _config
|
|
50
|
+
if _initialized:
|
|
51
|
+
return
|
|
52
|
+
if os.getenv("METERGRAPH_DISABLED") == "1" or disabled:
|
|
53
|
+
_initialized = True
|
|
54
|
+
return
|
|
55
|
+
token = token or os.getenv("METERGRAPH_APP_TOKEN")
|
|
56
|
+
ingest_url = ingest_url or os.getenv("METERGRAPH_INGEST_URL") or DEFAULT_INGEST_URL
|
|
57
|
+
if not token or not ingest_url:
|
|
58
|
+
# Stay uninitialized so a later init() that supplies a token succeeds.
|
|
59
|
+
if not _warned_no_token:
|
|
60
|
+
_warned_no_token = True
|
|
61
|
+
log.warning(
|
|
62
|
+
"Metergraph capture disabled: token and ingest URL are required"
|
|
63
|
+
)
|
|
64
|
+
return
|
|
65
|
+
_initialized = True
|
|
66
|
+
try:
|
|
67
|
+
_writer = Writer(
|
|
68
|
+
token,
|
|
69
|
+
ingest_url,
|
|
70
|
+
queue_size=int(os.getenv("METERGRAPH_QUEUE_SIZE", "2000")),
|
|
71
|
+
batch_size=int(os.getenv("METERGRAPH_BATCH_SIZE", "100")),
|
|
72
|
+
flush_seconds=float(os.getenv("METERGRAPH_FLUSH_SECONDS", "5")),
|
|
73
|
+
)
|
|
74
|
+
options = Options(
|
|
75
|
+
capture_text=(
|
|
76
|
+
_env_bool("METERGRAPH_CAPTURE_TEXT", False)
|
|
77
|
+
if capture_text is None
|
|
78
|
+
else capture_text
|
|
79
|
+
),
|
|
80
|
+
redact=redact,
|
|
81
|
+
app_root=os.path.realpath(app_root or os.getcwd()),
|
|
82
|
+
skip_frames=tuple(skip_frames or ()),
|
|
83
|
+
environment=environment or os.getenv("METERGRAPH_ENV"),
|
|
84
|
+
text_max_bytes=int(os.getenv("METERGRAPH_TEXT_MAX_BYTES", "100000")),
|
|
85
|
+
)
|
|
86
|
+
set_runtime(Runtime(_writer, options))
|
|
87
|
+
_config = ConfigPoller(
|
|
88
|
+
token,
|
|
89
|
+
ingest_url,
|
|
90
|
+
poll_seconds=float(os.getenv("METERGRAPH_CONFIG_POLL_SECONDS", "30")),
|
|
91
|
+
hard_ttl_seconds=float(
|
|
92
|
+
os.getenv("METERGRAPH_CONFIG_HARD_TTL_SECONDS", "120")
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
atexit.register(shutdown)
|
|
96
|
+
except Exception:
|
|
97
|
+
set_runtime(None)
|
|
98
|
+
if _writer:
|
|
99
|
+
_writer.shutdown()
|
|
100
|
+
_writer = None
|
|
101
|
+
_config = None
|
|
102
|
+
log.warning(
|
|
103
|
+
"Metergraph initialization failed; application is running uninstrumented"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def wrap(client: Any, *, provider: str | None = None) -> Any:
|
|
108
|
+
"""Wrap an OpenAI, Anthropic, or Google client for capture.
|
|
109
|
+
|
|
110
|
+
Calls init() automatically, so with env-var configuration this is the
|
|
111
|
+
only setup line needed. Call init(...) first to pass options in code.
|
|
112
|
+
"""
|
|
113
|
+
init()
|
|
114
|
+
return _wrap(client, provider=provider)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def model_for(route_name: str, *, default: str, session_key: str | None = None) -> str:
|
|
118
|
+
"""Return a sticky canary model, or the incumbent on every failure path."""
|
|
119
|
+
if _config is None:
|
|
120
|
+
return default
|
|
121
|
+
return _config.model_for(route_name, default, session_key or snapshot().session_id)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def record_outcome(
|
|
125
|
+
route_name: str,
|
|
126
|
+
*,
|
|
127
|
+
model: str,
|
|
128
|
+
task_completed: bool,
|
|
129
|
+
session_key: str | None = None,
|
|
130
|
+
feedback_score: float | None = None,
|
|
131
|
+
turns_to_resolution: int | None = None,
|
|
132
|
+
escalated: bool | None = None,
|
|
133
|
+
abandoned: bool | None = None,
|
|
134
|
+
edit_distance_ratio: float | None = None,
|
|
135
|
+
regeneration_count: int | None = None,
|
|
136
|
+
event_id: str | None = None,
|
|
137
|
+
) -> bool:
|
|
138
|
+
"""Enqueue a content-free real outcome without touching the request path."""
|
|
139
|
+
if _writer is None or not isinstance(task_completed, bool):
|
|
140
|
+
return False
|
|
141
|
+
route_name = str(route_name).strip()[:512]
|
|
142
|
+
model = str(model).strip()[:512]
|
|
143
|
+
session_key = str(session_key or snapshot().session_id or "").strip()[:512]
|
|
144
|
+
event_id = str(event_id or uuid.uuid4()).strip()[:128]
|
|
145
|
+
try:
|
|
146
|
+
feedback_score = float(feedback_score) if feedback_score is not None else None
|
|
147
|
+
turns_to_resolution = (
|
|
148
|
+
int(turns_to_resolution) if turns_to_resolution is not None else None
|
|
149
|
+
)
|
|
150
|
+
edit_distance_ratio = (
|
|
151
|
+
float(edit_distance_ratio) if edit_distance_ratio is not None else None
|
|
152
|
+
)
|
|
153
|
+
regeneration_count = (
|
|
154
|
+
int(regeneration_count) if regeneration_count is not None else None
|
|
155
|
+
)
|
|
156
|
+
except (TypeError, ValueError, OverflowError):
|
|
157
|
+
return False
|
|
158
|
+
if not route_name or not model or not session_key or not event_id:
|
|
159
|
+
return False
|
|
160
|
+
if feedback_score is not None and not -1 <= feedback_score <= 1:
|
|
161
|
+
return False
|
|
162
|
+
if turns_to_resolution is not None and not 1 <= turns_to_resolution <= 1_000_000:
|
|
163
|
+
return False
|
|
164
|
+
if edit_distance_ratio is not None and not 0 <= edit_distance_ratio <= 1:
|
|
165
|
+
return False
|
|
166
|
+
if regeneration_count is not None and not 0 <= regeneration_count <= 1_000_000:
|
|
167
|
+
return False
|
|
168
|
+
if escalated is not None and not isinstance(escalated, bool):
|
|
169
|
+
return False
|
|
170
|
+
if abandoned is not None and not isinstance(abandoned, bool):
|
|
171
|
+
return False
|
|
172
|
+
return _writer.enqueue(
|
|
173
|
+
{
|
|
174
|
+
"event_type": "outcome",
|
|
175
|
+
"event_id": event_id,
|
|
176
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
177
|
+
"route": route_name,
|
|
178
|
+
"session_id": session_key,
|
|
179
|
+
"model": model,
|
|
180
|
+
"task_completed": task_completed,
|
|
181
|
+
"feedback_score": feedback_score,
|
|
182
|
+
"turns_to_resolution": turns_to_resolution,
|
|
183
|
+
"escalated": escalated,
|
|
184
|
+
"abandoned": abandoned,
|
|
185
|
+
"edit_distance_ratio": edit_distance_ratio,
|
|
186
|
+
"regeneration_count": regeneration_count,
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def flush(timeout: float = 3.0) -> bool:
|
|
192
|
+
return True if _writer is None else _writer.flush(timeout)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def shutdown() -> None:
|
|
196
|
+
global _writer, _config
|
|
197
|
+
if _config:
|
|
198
|
+
_config.stop()
|
|
199
|
+
_config = None
|
|
200
|
+
if _writer:
|
|
201
|
+
_writer.shutdown()
|
|
202
|
+
_writer = None
|
|
203
|
+
set_runtime(None)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = [
|
|
207
|
+
"DEFAULT_INGEST_URL",
|
|
208
|
+
"flush",
|
|
209
|
+
"init",
|
|
210
|
+
"model_for",
|
|
211
|
+
"record_outcome",
|
|
212
|
+
"route",
|
|
213
|
+
"set_session",
|
|
214
|
+
"set_tags",
|
|
215
|
+
"shutdown",
|
|
216
|
+
"track",
|
|
217
|
+
"wrap",
|
|
218
|
+
"wrap_executor",
|
|
219
|
+
]
|