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 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
+ ]