xtrace-sdk 0.0.3__tar.gz
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.
- xtrace_sdk-0.0.3/PKG-INFO +6 -0
- xtrace_sdk-0.0.3/pyproject.toml +15 -0
- xtrace_sdk-0.0.3/setup.cfg +4 -0
- xtrace_sdk-0.0.3/xtrace_sdk/__init__.py +4 -0
- xtrace_sdk-0.0.3/xtrace_sdk/client.py +316 -0
- xtrace_sdk-0.0.3/xtrace_sdk/openai.py +364 -0
- xtrace_sdk-0.0.3/xtrace_sdk.egg-info/PKG-INFO +6 -0
- xtrace_sdk-0.0.3/xtrace_sdk.egg-info/SOURCES.txt +9 -0
- xtrace_sdk-0.0.3/xtrace_sdk.egg-info/dependency_links.txt +1 -0
- xtrace_sdk-0.0.3/xtrace_sdk.egg-info/requires.txt +1 -0
- xtrace_sdk-0.0.3/xtrace_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xtrace-sdk"
|
|
7
|
+
version = "0.0.3"
|
|
8
|
+
description = "XTrace Python SDK"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"requests>=2.31",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.setuptools]
|
|
15
|
+
packages = ["xtrace_sdk"]
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Dict, List, Optional, Union
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class XTraceConfig:
|
|
17
|
+
base_url: str
|
|
18
|
+
api_key: str
|
|
19
|
+
default_project_id: str
|
|
20
|
+
queue_max_size: int = 10_000
|
|
21
|
+
batch_max_size: int = 100
|
|
22
|
+
flush_interval_s: float = 0.5
|
|
23
|
+
request_timeout_s: float = 2.0
|
|
24
|
+
max_retries: int = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class XTraceClient:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
base_url: Optional[str] = None,
|
|
31
|
+
api_key: Optional[str] = None,
|
|
32
|
+
default_project_id: Optional[str] = None,
|
|
33
|
+
*,
|
|
34
|
+
queue_max_size: int = 10_000,
|
|
35
|
+
batch_max_size: int = 100,
|
|
36
|
+
flush_interval_s: float = 0.5,
|
|
37
|
+
request_timeout_s: float = 2.0,
|
|
38
|
+
max_retries: int = 3,
|
|
39
|
+
) -> None:
|
|
40
|
+
base_url = base_url or os.environ.get("XTRACE_BASE_URL") or "http://127.0.0.1:8742"
|
|
41
|
+
api_key = api_key or os.environ.get("XTRACE_API_KEY") or os.environ.get("XTRACE_BEARER_TOKEN")
|
|
42
|
+
if not api_key:
|
|
43
|
+
raise ValueError("missing api_key (env XTRACE_API_KEY or XTRACE_BEARER_TOKEN)")
|
|
44
|
+
|
|
45
|
+
default_project_id = default_project_id or os.environ.get("XTRACE_PROJECT_ID") or "default"
|
|
46
|
+
|
|
47
|
+
self._cfg = XTraceConfig(
|
|
48
|
+
base_url=base_url.rstrip("/"),
|
|
49
|
+
api_key=api_key,
|
|
50
|
+
default_project_id=default_project_id,
|
|
51
|
+
queue_max_size=queue_max_size,
|
|
52
|
+
batch_max_size=batch_max_size,
|
|
53
|
+
flush_interval_s=flush_interval_s,
|
|
54
|
+
request_timeout_s=request_timeout_s,
|
|
55
|
+
max_retries=max_retries,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
self._q: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=self._cfg.queue_max_size)
|
|
59
|
+
self._stop = threading.Event()
|
|
60
|
+
self._worker = threading.Thread(target=self._run, name="xtrace-ingest", daemon=True)
|
|
61
|
+
|
|
62
|
+
self.dropped_events = 0
|
|
63
|
+
self.sent_batches = 0
|
|
64
|
+
self.failed_batches = 0
|
|
65
|
+
|
|
66
|
+
self._worker.start()
|
|
67
|
+
atexit.register(self.shutdown, timeout_s=1.0)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def new_id() -> str:
|
|
71
|
+
return str(uuid.uuid4())
|
|
72
|
+
|
|
73
|
+
def enqueue_batch(self, payload: Dict[str, Any]) -> None:
|
|
74
|
+
try:
|
|
75
|
+
self._q.put_nowait(payload)
|
|
76
|
+
except queue.Full:
|
|
77
|
+
self.dropped_events += 1
|
|
78
|
+
|
|
79
|
+
def flush(self, timeout_s: float = 5.0) -> None:
|
|
80
|
+
deadline = time.time() + timeout_s
|
|
81
|
+
while time.time() < deadline:
|
|
82
|
+
if self._q.empty():
|
|
83
|
+
return
|
|
84
|
+
time.sleep(0.05)
|
|
85
|
+
|
|
86
|
+
def shutdown(self, timeout_s: float = 1.0) -> None:
|
|
87
|
+
if self._stop.is_set():
|
|
88
|
+
return
|
|
89
|
+
self._stop.set()
|
|
90
|
+
self.flush(timeout_s=timeout_s)
|
|
91
|
+
|
|
92
|
+
def _run(self) -> None:
|
|
93
|
+
session = requests.Session()
|
|
94
|
+
url = f"{self._cfg.base_url}/v1/l/batch"
|
|
95
|
+
headers = {
|
|
96
|
+
"Authorization": f"Bearer {self._cfg.api_key}",
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
buf: List[Dict[str, Any]] = []
|
|
101
|
+
last_flush = time.time()
|
|
102
|
+
|
|
103
|
+
while not self._stop.is_set():
|
|
104
|
+
timeout = max(0.0, self._cfg.flush_interval_s - (time.time() - last_flush))
|
|
105
|
+
try:
|
|
106
|
+
item = self._q.get(timeout=timeout)
|
|
107
|
+
buf.append(item)
|
|
108
|
+
except queue.Empty:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
if not buf:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if len(buf) < self._cfg.batch_max_size and (time.time() - last_flush) < self._cfg.flush_interval_s:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
payloads = self._split_by_trace(buf)
|
|
118
|
+
buf.clear()
|
|
119
|
+
last_flush = time.time()
|
|
120
|
+
|
|
121
|
+
for payload in payloads:
|
|
122
|
+
ok = self._post_with_retry(session, url, headers, payload)
|
|
123
|
+
if ok:
|
|
124
|
+
self.sent_batches += 1
|
|
125
|
+
else:
|
|
126
|
+
self.failed_batches += 1
|
|
127
|
+
|
|
128
|
+
# best-effort drain
|
|
129
|
+
drain: List[Dict[str, Any]] = []
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
drain.append(self._q.get_nowait())
|
|
133
|
+
except queue.Empty:
|
|
134
|
+
break
|
|
135
|
+
if drain:
|
|
136
|
+
for payload in self._split_by_trace(drain):
|
|
137
|
+
ok = self._post_with_retry(session, url, headers, payload)
|
|
138
|
+
if ok:
|
|
139
|
+
self.sent_batches += 1
|
|
140
|
+
else:
|
|
141
|
+
self.failed_batches += 1
|
|
142
|
+
|
|
143
|
+
def _split_by_trace(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
144
|
+
grouped: Dict[str, Dict[str, Any]] = {}
|
|
145
|
+
|
|
146
|
+
for it in items:
|
|
147
|
+
trace = it.get("trace")
|
|
148
|
+
observations = it.get("observations") or []
|
|
149
|
+
|
|
150
|
+
trace_id = None
|
|
151
|
+
if isinstance(trace, dict):
|
|
152
|
+
trace_id = trace.get("id")
|
|
153
|
+
if trace_id is None and observations:
|
|
154
|
+
first = observations[0]
|
|
155
|
+
if isinstance(first, dict):
|
|
156
|
+
trace_id = first.get("traceId")
|
|
157
|
+
|
|
158
|
+
if trace_id is None:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if trace_id not in grouped:
|
|
162
|
+
grouped[trace_id] = {"trace": None, "observations": []}
|
|
163
|
+
|
|
164
|
+
if grouped[trace_id]["trace"] is None and trace is not None:
|
|
165
|
+
grouped[trace_id]["trace"] = trace
|
|
166
|
+
|
|
167
|
+
grouped[trace_id]["observations"].extend(observations)
|
|
168
|
+
|
|
169
|
+
return list(grouped.values())
|
|
170
|
+
|
|
171
|
+
def _post_with_retry(
|
|
172
|
+
self,
|
|
173
|
+
session: requests.Session,
|
|
174
|
+
url: str,
|
|
175
|
+
headers: Dict[str, str],
|
|
176
|
+
payload: Dict[str, Any],
|
|
177
|
+
) -> bool:
|
|
178
|
+
delay = 0.2
|
|
179
|
+
for attempt in range(self._cfg.max_retries + 1):
|
|
180
|
+
try:
|
|
181
|
+
resp = session.post(url, headers=headers, json=payload, timeout=self._cfg.request_timeout_s)
|
|
182
|
+
if 200 <= resp.status_code < 300:
|
|
183
|
+
return True
|
|
184
|
+
if resp.status_code == 429 or 500 <= resp.status_code < 600:
|
|
185
|
+
time.sleep(delay)
|
|
186
|
+
delay = min(delay * 2.0, 5.0)
|
|
187
|
+
continue
|
|
188
|
+
return False
|
|
189
|
+
except requests.RequestException:
|
|
190
|
+
if attempt >= self._cfg.max_retries:
|
|
191
|
+
return False
|
|
192
|
+
time.sleep(delay)
|
|
193
|
+
delay = min(delay * 2.0, 5.0)
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
def push_metrics(self, metrics: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
197
|
+
url = f"{self._cfg.base_url}/v1/metrics/batch"
|
|
198
|
+
headers = {
|
|
199
|
+
"Authorization": f"Bearer {self._cfg.api_key}",
|
|
200
|
+
"Content-Type": "application/json",
|
|
201
|
+
}
|
|
202
|
+
payload = {"metrics": [self._normalize_metric_point(m) for m in metrics]}
|
|
203
|
+
|
|
204
|
+
delay = 0.2
|
|
205
|
+
with requests.Session() as session:
|
|
206
|
+
for attempt in range(self._cfg.max_retries + 1):
|
|
207
|
+
try:
|
|
208
|
+
resp = session.post(
|
|
209
|
+
url,
|
|
210
|
+
headers=headers,
|
|
211
|
+
json=payload,
|
|
212
|
+
timeout=self._cfg.request_timeout_s,
|
|
213
|
+
)
|
|
214
|
+
if 200 <= resp.status_code < 300:
|
|
215
|
+
return resp.json()
|
|
216
|
+
if resp.status_code == 429 or 500 <= resp.status_code < 600:
|
|
217
|
+
if attempt >= self._cfg.max_retries:
|
|
218
|
+
break
|
|
219
|
+
time.sleep(delay)
|
|
220
|
+
delay = min(delay * 2.0, 5.0)
|
|
221
|
+
continue
|
|
222
|
+
resp.raise_for_status()
|
|
223
|
+
except requests.RequestException:
|
|
224
|
+
if attempt >= self._cfg.max_retries:
|
|
225
|
+
break
|
|
226
|
+
time.sleep(delay)
|
|
227
|
+
delay = min(delay * 2.0, 5.0)
|
|
228
|
+
|
|
229
|
+
raise RuntimeError("push_metrics failed")
|
|
230
|
+
|
|
231
|
+
def metrics_names(self) -> List[str]:
|
|
232
|
+
url = f"{self._cfg.base_url}/api/public/metrics/names"
|
|
233
|
+
headers = {
|
|
234
|
+
"Authorization": f"Bearer {self._cfg.api_key}",
|
|
235
|
+
"Content-Type": "application/json",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
with requests.Session() as session:
|
|
239
|
+
resp = session.get(url, headers=headers, timeout=self._cfg.request_timeout_s)
|
|
240
|
+
resp.raise_for_status()
|
|
241
|
+
data = resp.json()
|
|
242
|
+
|
|
243
|
+
names = data.get("data") if isinstance(data, dict) else None
|
|
244
|
+
if not isinstance(names, list):
|
|
245
|
+
raise RuntimeError("invalid metrics_names response")
|
|
246
|
+
return names
|
|
247
|
+
|
|
248
|
+
def metrics_query(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
name: str,
|
|
252
|
+
from_ts: Optional[Union[str, datetime]] = None,
|
|
253
|
+
to_ts: Optional[Union[str, datetime]] = None,
|
|
254
|
+
labels: Optional[Dict[str, str]] = None,
|
|
255
|
+
step: Optional[str] = None,
|
|
256
|
+
agg: Optional[str] = None,
|
|
257
|
+
) -> Dict[str, Any]:
|
|
258
|
+
url = f"{self._cfg.base_url}/api/public/metrics/query"
|
|
259
|
+
headers = {
|
|
260
|
+
"Authorization": f"Bearer {self._cfg.api_key}",
|
|
261
|
+
"Content-Type": "application/json",
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
params: Dict[str, str] = {"name": name}
|
|
265
|
+
if from_ts is not None:
|
|
266
|
+
params["from"] = self._normalize_ts(from_ts)
|
|
267
|
+
if to_ts is not None:
|
|
268
|
+
params["to"] = self._normalize_ts(to_ts)
|
|
269
|
+
if labels is not None:
|
|
270
|
+
params["labels"] = json.dumps(labels, separators=(",", ":"), ensure_ascii=False)
|
|
271
|
+
if step is not None:
|
|
272
|
+
params["step"] = step
|
|
273
|
+
if agg is not None:
|
|
274
|
+
params["agg"] = agg
|
|
275
|
+
|
|
276
|
+
with requests.Session() as session:
|
|
277
|
+
resp = session.get(url, headers=headers, params=params, timeout=self._cfg.request_timeout_s)
|
|
278
|
+
resp.raise_for_status()
|
|
279
|
+
data = resp.json()
|
|
280
|
+
|
|
281
|
+
if not isinstance(data, dict) or "data" not in data:
|
|
282
|
+
raise RuntimeError("invalid metrics_query response")
|
|
283
|
+
return data
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def _normalize_ts(ts: Union[str, datetime]) -> str:
|
|
287
|
+
if isinstance(ts, str):
|
|
288
|
+
return ts
|
|
289
|
+
if ts.tzinfo is None:
|
|
290
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
291
|
+
return ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
292
|
+
|
|
293
|
+
def _normalize_metric_point(self, m: Dict[str, Any]) -> Dict[str, Any]:
|
|
294
|
+
name = m.get("name")
|
|
295
|
+
value = m.get("value")
|
|
296
|
+
timestamp = m.get("timestamp")
|
|
297
|
+
|
|
298
|
+
if not isinstance(name, str) or not name:
|
|
299
|
+
raise ValueError("metric.name must be non-empty string")
|
|
300
|
+
if not isinstance(value, (int, float)):
|
|
301
|
+
raise ValueError("metric.value must be number")
|
|
302
|
+
if timestamp is None:
|
|
303
|
+
raise ValueError("metric.timestamp is required")
|
|
304
|
+
|
|
305
|
+
labels = m.get("labels")
|
|
306
|
+
if labels is None:
|
|
307
|
+
labels = {}
|
|
308
|
+
if not isinstance(labels, dict):
|
|
309
|
+
raise ValueError("metric.labels must be dict")
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"name": name,
|
|
313
|
+
"labels": {str(k): str(v) for k, v in labels.items()},
|
|
314
|
+
"value": float(value),
|
|
315
|
+
"timestamp": self._normalize_ts(timestamp),
|
|
316
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Dict, Iterable, Optional
|
|
3
|
+
|
|
4
|
+
from .client import XTraceClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def observe_openai(
|
|
8
|
+
client: Any,
|
|
9
|
+
*,
|
|
10
|
+
xtrace: XTraceClient,
|
|
11
|
+
name: Optional[str] = None,
|
|
12
|
+
user_id: Optional[str] = None,
|
|
13
|
+
session_id: Optional[str] = None,
|
|
14
|
+
tags: Optional[list[str]] = None,
|
|
15
|
+
metadata: Optional[dict] = None,
|
|
16
|
+
project_id: Optional[str] = None,
|
|
17
|
+
) -> Any:
|
|
18
|
+
return _OpenAIClientWrapper(
|
|
19
|
+
client,
|
|
20
|
+
xtrace=xtrace,
|
|
21
|
+
name=name,
|
|
22
|
+
user_id=user_id,
|
|
23
|
+
session_id=session_id,
|
|
24
|
+
tags=tags or [],
|
|
25
|
+
metadata=metadata or {},
|
|
26
|
+
project_id=project_id,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _OpenAIClientWrapper:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
client: Any,
|
|
34
|
+
*,
|
|
35
|
+
xtrace: XTraceClient,
|
|
36
|
+
name: Optional[str],
|
|
37
|
+
user_id: Optional[str],
|
|
38
|
+
session_id: Optional[str],
|
|
39
|
+
tags: list[str],
|
|
40
|
+
metadata: dict,
|
|
41
|
+
project_id: Optional[str],
|
|
42
|
+
) -> None:
|
|
43
|
+
self._client = client
|
|
44
|
+
self._xtrace = xtrace
|
|
45
|
+
self._name = name
|
|
46
|
+
self._user_id = user_id
|
|
47
|
+
self._session_id = session_id
|
|
48
|
+
self._tags = tags
|
|
49
|
+
self._metadata = metadata
|
|
50
|
+
self._project_id = project_id
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def chat(self) -> Any:
|
|
54
|
+
return _ChatWrapper(self)
|
|
55
|
+
|
|
56
|
+
def __getattr__(self, item: str) -> Any:
|
|
57
|
+
return getattr(self._client, item)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _ChatWrapper:
|
|
61
|
+
def __init__(self, root: _OpenAIClientWrapper) -> None:
|
|
62
|
+
self._root = root
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def completions(self) -> Any:
|
|
66
|
+
return _ChatCompletionsWrapper(self._root)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _ChatCompletionsWrapper:
|
|
70
|
+
def __init__(self, root: _OpenAIClientWrapper) -> None:
|
|
71
|
+
self._root = root
|
|
72
|
+
|
|
73
|
+
def create(self, *args: Any, **kwargs: Any) -> Any:
|
|
74
|
+
trace_id = self._root._xtrace.new_id()
|
|
75
|
+
obs_id = self._root._xtrace.new_id()
|
|
76
|
+
|
|
77
|
+
start = time.time()
|
|
78
|
+
stream = bool(kwargs.get("stream", False))
|
|
79
|
+
|
|
80
|
+
result = self._root._client.chat.completions.create(*args, **kwargs)
|
|
81
|
+
|
|
82
|
+
if stream:
|
|
83
|
+
return _StreamWrapper(
|
|
84
|
+
result,
|
|
85
|
+
root=self._root,
|
|
86
|
+
trace_id=trace_id,
|
|
87
|
+
obs_id=obs_id,
|
|
88
|
+
start=start,
|
|
89
|
+
kwargs=kwargs,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
latency = time.time() - start
|
|
93
|
+
_record_non_stream(
|
|
94
|
+
root=self._root,
|
|
95
|
+
trace_id=trace_id,
|
|
96
|
+
obs_id=obs_id,
|
|
97
|
+
start=start,
|
|
98
|
+
latency=latency,
|
|
99
|
+
response=result,
|
|
100
|
+
kwargs=kwargs,
|
|
101
|
+
)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _StreamWrapper:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
inner: Iterable[Any],
|
|
109
|
+
*,
|
|
110
|
+
root: _OpenAIClientWrapper,
|
|
111
|
+
trace_id: str,
|
|
112
|
+
obs_id: str,
|
|
113
|
+
start: float,
|
|
114
|
+
kwargs: Dict[str, Any],
|
|
115
|
+
) -> None:
|
|
116
|
+
self._inner = iter(inner)
|
|
117
|
+
self._root = root
|
|
118
|
+
self._trace_id = trace_id
|
|
119
|
+
self._obs_id = obs_id
|
|
120
|
+
self._start = start
|
|
121
|
+
self._kwargs = kwargs
|
|
122
|
+
self._ttfb: Optional[float] = None
|
|
123
|
+
self._output_parts: list[str] = []
|
|
124
|
+
self._usage: Optional[Dict[str, Any]] = None
|
|
125
|
+
|
|
126
|
+
def __iter__(self):
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __next__(self):
|
|
130
|
+
try:
|
|
131
|
+
chunk = next(self._inner)
|
|
132
|
+
except StopIteration:
|
|
133
|
+
self._finalize()
|
|
134
|
+
raise
|
|
135
|
+
else:
|
|
136
|
+
delta = _extract_stream_delta_text(chunk)
|
|
137
|
+
if delta:
|
|
138
|
+
if self._ttfb is None:
|
|
139
|
+
self._ttfb = time.time() - self._start
|
|
140
|
+
self._output_parts.append(delta)
|
|
141
|
+
|
|
142
|
+
usage = _extract_stream_usage(chunk)
|
|
143
|
+
if usage is not None:
|
|
144
|
+
self._usage = usage
|
|
145
|
+
return chunk
|
|
146
|
+
|
|
147
|
+
def close(self) -> None:
|
|
148
|
+
self._finalize()
|
|
149
|
+
|
|
150
|
+
def _finalize(self) -> None:
|
|
151
|
+
latency = time.time() - self._start
|
|
152
|
+
output_text = "".join(self._output_parts) if self._output_parts else None
|
|
153
|
+
|
|
154
|
+
payload = _build_payload(
|
|
155
|
+
root=self._root,
|
|
156
|
+
trace_id=self._trace_id,
|
|
157
|
+
obs_id=self._obs_id,
|
|
158
|
+
start=self._start,
|
|
159
|
+
latency=latency,
|
|
160
|
+
ttfb=self._ttfb,
|
|
161
|
+
output_text=output_text,
|
|
162
|
+
usage=self._usage,
|
|
163
|
+
kwargs=self._kwargs,
|
|
164
|
+
)
|
|
165
|
+
self._root._xtrace.enqueue_batch(payload)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _record_non_stream(
|
|
169
|
+
*,
|
|
170
|
+
root: _OpenAIClientWrapper,
|
|
171
|
+
trace_id: str,
|
|
172
|
+
obs_id: str,
|
|
173
|
+
start: float,
|
|
174
|
+
latency: float,
|
|
175
|
+
response: Any,
|
|
176
|
+
kwargs: Dict[str, Any],
|
|
177
|
+
) -> None:
|
|
178
|
+
usage = _extract_usage(response)
|
|
179
|
+
output_text = _extract_output_text(response)
|
|
180
|
+
|
|
181
|
+
payload = _build_payload(
|
|
182
|
+
root=root,
|
|
183
|
+
trace_id=trace_id,
|
|
184
|
+
obs_id=obs_id,
|
|
185
|
+
start=start,
|
|
186
|
+
latency=latency,
|
|
187
|
+
ttfb=None,
|
|
188
|
+
output_text=output_text,
|
|
189
|
+
usage=usage,
|
|
190
|
+
kwargs=kwargs,
|
|
191
|
+
)
|
|
192
|
+
root._xtrace.enqueue_batch(payload)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _build_payload(
|
|
196
|
+
*,
|
|
197
|
+
root: _OpenAIClientWrapper,
|
|
198
|
+
trace_id: str,
|
|
199
|
+
obs_id: str,
|
|
200
|
+
start: float,
|
|
201
|
+
latency: float,
|
|
202
|
+
ttfb: Optional[float],
|
|
203
|
+
output_text: Optional[str],
|
|
204
|
+
usage: Optional[Dict[str, Any]],
|
|
205
|
+
kwargs: Dict[str, Any],
|
|
206
|
+
) -> Dict[str, Any]:
|
|
207
|
+
ts_iso = _to_iso(start)
|
|
208
|
+
end_iso = _to_iso(start + latency)
|
|
209
|
+
completion_iso = _to_iso(start + ttfb) if ttfb is not None else None
|
|
210
|
+
|
|
211
|
+
trace = {
|
|
212
|
+
"id": trace_id,
|
|
213
|
+
"timestamp": ts_iso,
|
|
214
|
+
"name": kwargs.get("name") or root._name,
|
|
215
|
+
"userId": root._user_id,
|
|
216
|
+
"sessionId": root._session_id,
|
|
217
|
+
"tags": root._tags,
|
|
218
|
+
"metadata": root._metadata or None,
|
|
219
|
+
"projectId": root._project_id or root._xtrace._cfg.default_project_id,
|
|
220
|
+
"latency": latency,
|
|
221
|
+
"totalCost": None,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
messages = kwargs.get("messages")
|
|
225
|
+
model = kwargs.get("model")
|
|
226
|
+
|
|
227
|
+
obs = {
|
|
228
|
+
"id": obs_id,
|
|
229
|
+
"traceId": trace_id,
|
|
230
|
+
"type": "GENERATION",
|
|
231
|
+
"name": "chat",
|
|
232
|
+
"startTime": ts_iso,
|
|
233
|
+
"endTime": end_iso,
|
|
234
|
+
"completionStartTime": completion_iso,
|
|
235
|
+
"model": model,
|
|
236
|
+
"modelParameters": None,
|
|
237
|
+
"input": messages,
|
|
238
|
+
"output": output_text,
|
|
239
|
+
"usage": usage,
|
|
240
|
+
"level": "DEFAULT",
|
|
241
|
+
"statusMessage": None,
|
|
242
|
+
"parentObservationId": None,
|
|
243
|
+
"promptId": None,
|
|
244
|
+
"promptName": None,
|
|
245
|
+
"promptVersion": None,
|
|
246
|
+
"modelId": None,
|
|
247
|
+
"inputPrice": None,
|
|
248
|
+
"outputPrice": None,
|
|
249
|
+
"totalPrice": None,
|
|
250
|
+
"calculatedInputCost": None,
|
|
251
|
+
"calculatedOutputCost": None,
|
|
252
|
+
"calculatedTotalCost": None,
|
|
253
|
+
"latency": latency,
|
|
254
|
+
"timeToFirstToken": ttfb,
|
|
255
|
+
"completionTokens": _safe_get_int(usage, ["output"]),
|
|
256
|
+
"promptTokens": _safe_get_int(usage, ["input"]),
|
|
257
|
+
"totalTokens": _safe_get_int(usage, ["total"]),
|
|
258
|
+
"unit": _safe_get_str(usage, ["unit"]),
|
|
259
|
+
"metadata": kwargs.get("metadata"),
|
|
260
|
+
"projectId": root._project_id or root._xtrace._cfg.default_project_id,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {"trace": trace, "observations": [obs]}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _to_iso(ts: float) -> str:
|
|
267
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(ts)) + f".{int((ts % 1) * 1_000_000):06d}Z"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _extract_output_text(resp: Any) -> Optional[str]:
|
|
271
|
+
try:
|
|
272
|
+
choice0 = resp.choices[0]
|
|
273
|
+
msg = getattr(choice0, "message", None)
|
|
274
|
+
if msg is not None:
|
|
275
|
+
return getattr(msg, "content", None)
|
|
276
|
+
except Exception:
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _extract_stream_usage(chunk: Any) -> Optional[Dict[str, Any]]:
|
|
281
|
+
# Best-effort: depends on provider and whether stream_options.include_usage is enabled.
|
|
282
|
+
try:
|
|
283
|
+
u = getattr(chunk, "usage", None)
|
|
284
|
+
if u is not None:
|
|
285
|
+
prompt = getattr(u, "prompt_tokens", None)
|
|
286
|
+
completion = getattr(u, "completion_tokens", None)
|
|
287
|
+
total = getattr(u, "total_tokens", None)
|
|
288
|
+
if prompt is None and completion is None and total is None:
|
|
289
|
+
return None
|
|
290
|
+
return {
|
|
291
|
+
"input": int(prompt or 0),
|
|
292
|
+
"output": int(completion or 0),
|
|
293
|
+
"total": int(total or ((prompt or 0) + (completion or 0))),
|
|
294
|
+
"unit": "TOKENS",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# allow custom injected dict
|
|
298
|
+
u2 = getattr(chunk, "xtrace_usage", None)
|
|
299
|
+
if isinstance(u2, dict):
|
|
300
|
+
return u2
|
|
301
|
+
return None
|
|
302
|
+
except Exception:
|
|
303
|
+
return None
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _extract_usage(resp: Any) -> Optional[Dict[str, Any]]:
|
|
308
|
+
try:
|
|
309
|
+
u = getattr(resp, "usage", None)
|
|
310
|
+
if u is None:
|
|
311
|
+
return None
|
|
312
|
+
prompt = getattr(u, "prompt_tokens", None)
|
|
313
|
+
completion = getattr(u, "completion_tokens", None)
|
|
314
|
+
total = getattr(u, "total_tokens", None)
|
|
315
|
+
if prompt is None and completion is None and total is None:
|
|
316
|
+
return None
|
|
317
|
+
return {
|
|
318
|
+
"input": int(prompt or 0),
|
|
319
|
+
"output": int(completion or 0),
|
|
320
|
+
"total": int(total or ((prompt or 0) + (completion or 0))),
|
|
321
|
+
"unit": "TOKENS",
|
|
322
|
+
}
|
|
323
|
+
except Exception:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _extract_stream_delta_text(chunk: Any) -> Optional[str]:
|
|
328
|
+
try:
|
|
329
|
+
choices = getattr(chunk, "choices", None)
|
|
330
|
+
if not choices:
|
|
331
|
+
return None
|
|
332
|
+
delta = getattr(choices[0], "delta", None)
|
|
333
|
+
if delta is None:
|
|
334
|
+
return None
|
|
335
|
+
return getattr(delta, "content", None)
|
|
336
|
+
except Exception:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _safe_get_int(d: Optional[Dict[str, Any]], path: list[str]) -> Optional[int]:
|
|
341
|
+
if not d:
|
|
342
|
+
return None
|
|
343
|
+
cur: Any = d
|
|
344
|
+
for p in path:
|
|
345
|
+
if not isinstance(cur, dict) or p not in cur:
|
|
346
|
+
return None
|
|
347
|
+
cur = cur[p]
|
|
348
|
+
try:
|
|
349
|
+
return int(cur)
|
|
350
|
+
except Exception:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _safe_get_str(d: Optional[Dict[str, Any]], path: list[str]) -> Optional[str]:
|
|
355
|
+
if not d:
|
|
356
|
+
return None
|
|
357
|
+
cur: Any = d
|
|
358
|
+
for p in path:
|
|
359
|
+
if not isinstance(cur, dict) or p not in cur:
|
|
360
|
+
return None
|
|
361
|
+
cur = cur[p]
|
|
362
|
+
if cur is None:
|
|
363
|
+
return None
|
|
364
|
+
return str(cur)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
xtrace_sdk/__init__.py
|
|
3
|
+
xtrace_sdk/client.py
|
|
4
|
+
xtrace_sdk/openai.py
|
|
5
|
+
xtrace_sdk.egg-info/PKG-INFO
|
|
6
|
+
xtrace_sdk.egg-info/SOURCES.txt
|
|
7
|
+
xtrace_sdk.egg-info/dependency_links.txt
|
|
8
|
+
xtrace_sdk.egg-info/requires.txt
|
|
9
|
+
xtrace_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.31
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xtrace_sdk
|