tracify-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.
tracify/__init__.py ADDED
@@ -0,0 +1,596 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import uuid
5
+ import functools
6
+ import asyncio
7
+ import threading
8
+ from datetime import datetime
9
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Callable, Dict, List, Optional, Union
12
+
13
+ try:
14
+ import httpx
15
+ _HAS_HTTPX = True
16
+ except ImportError:
17
+ _HAS_HTTPX = False
18
+
19
+ import requests as _requests_fallback
20
+
21
+
22
+ class LatencyBudgetExceeded(Exception):
23
+ """Raised when a call exceeds the latency budget and is aborted."""
24
+ pass
25
+
26
+
27
+ class CancellationToken:
28
+ """
29
+ Cooperative cancellation token passed to the user's call function.
30
+
31
+ When the latency budget expires, the SDK sets this token as cancelled.
32
+ The user's call function should check ``token.is_cancelled`` periodically
33
+ and raise or return early if true.
34
+
35
+ Example::
36
+
37
+ def my_llm_call(model: str, token: CancellationToken) -> dict:
38
+ # Check before making the HTTP call
39
+ if token.is_cancelled:
40
+ raise LatencyBudgetExceeded("Cancelled")
41
+ response = httpx.post(..., timeout=30)
42
+ return response.json()
43
+
44
+ For true cancellation of in-flight HTTP requests, the user should use
45
+ ``token`` with an httpx client::
46
+
47
+ def my_llm_call(model: str, token: CancellationToken) -> dict:
48
+ with httpx.Client(timeout=30) as client:
49
+ # httpx will close the connection if the client is garbage collected
50
+ # after the timeout fires and this function returns
51
+ return client.post(...).json()
52
+ """
53
+
54
+ def __init__(self):
55
+ self._cancelled = threading.Event()
56
+
57
+ @property
58
+ def is_cancelled(self) -> bool:
59
+ return self._cancelled.is_set()
60
+
61
+ def cancel(self):
62
+ self._cancelled.set()
63
+
64
+ def check(self):
65
+ """Raise LatencyBudgetExceeded if cancelled. Call this in long-running loops."""
66
+ if self._cancelled.is_set():
67
+ raise LatencyBudgetExceeded("Call cancelled by latency budget")
68
+
69
+
70
+ @dataclass
71
+ class RuntimePolicy:
72
+ enforcement_mode: str = "observe" # "observe" | "enforce"
73
+ max_cost_per_run: Optional[float] = None
74
+ max_cost_per_day: Optional[float] = None
75
+ fallback_chain: List[str] = field(default_factory=list)
76
+ max_attempts: int = 3
77
+ backoff_ms: float = 1000
78
+ backoff_multiplier: float = 2
79
+ retryable_errors: List[str] = field(default_factory=lambda: ["timeout", "5xx"])
80
+ latency_budget_ms: Optional[int] = None
81
+
82
+ def to_dict(self) -> Dict[str, Any]:
83
+ return {
84
+ "enforcementMode": self.enforcement_mode,
85
+ "maxCostPerRun": self.max_cost_per_run,
86
+ "maxCostPerDay": self.max_cost_per_day,
87
+ "fallbackChain": self.fallback_chain,
88
+ "retryPolicy": {
89
+ "maxAttempts": self.max_attempts,
90
+ "backoffMs": self.backoff_ms,
91
+ "backoffMultiplier": self.backoff_multiplier,
92
+ "retryableErrors": self.retryable_errors,
93
+ },
94
+ "latencyBudgetMs": self.latency_budget_ms,
95
+ }
96
+
97
+
98
+ def _is_retryable_error(error: Exception, retryable_errors: List[str]) -> bool:
99
+ if isinstance(error, LatencyBudgetExceeded):
100
+ return False
101
+ msg = str(error).lower()
102
+ status = getattr(error, "status_code", None) or getattr(error, "status", None) or 0
103
+
104
+ for code in retryable_errors:
105
+ if code == "timeout" and ("timeout" in msg or "timed out" in msg):
106
+ return True
107
+ if code == "5xx" and isinstance(status, int) and 500 <= status < 600:
108
+ return True
109
+ if code == "429" and (status == 429 or "rate limit" in msg or "too many requests" in msg):
110
+ return True
111
+ if code == "rate_limit" and (status == 429 or "rate limit" in msg or "throttl" in msg):
112
+ return True
113
+ if code == "overloaded" and ("overloaded" in msg or "capacity" in msg or "busy" in msg):
114
+ return True
115
+ return False
116
+
117
+
118
+ def _is_latency_abort(error: Exception) -> bool:
119
+ return isinstance(error, LatencyBudgetExceeded)
120
+
121
+
122
+ _EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="tracify-latency")
123
+
124
+
125
+ def _run_with_timeout(
126
+ func: Callable[[CancellationToken], Any],
127
+ timeout_ms: Optional[int],
128
+ ) -> Any:
129
+ """
130
+ Run func(token) in a thread with a cooperative timeout.
131
+
132
+ The function receives a CancellationToken. When the timeout fires,
133
+ the token is set to cancelled. The function is responsible for checking
134
+ token.is_cancelled (or calling token.check()) and exiting cleanly.
135
+
136
+ If the function does not check the token, it will continue running in
137
+ the background after LatencyBudgetExceeded is raised. This is the same
138
+ behavior as TS's AbortController when the user's call ignores the signal,
139
+ but in Python the "background" thread is more visible.
140
+
141
+ Returns the function's result, or raises LatencyBudgetExceeded on timeout.
142
+ """
143
+ if timeout_ms is None:
144
+ token = CancellationToken()
145
+ return func(token)
146
+
147
+ token = CancellationToken()
148
+ result = [None]
149
+ exception = [None]
150
+
151
+ def target():
152
+ try:
153
+ result[0] = func(token)
154
+ except Exception as e:
155
+ exception[0] = e
156
+
157
+ future = _EXECUTOR.submit(target)
158
+ try:
159
+ future.result(timeout=timeout_ms / 1000)
160
+ except FuturesTimeout:
161
+ token.cancel()
162
+ # Give the function a brief window to notice the cancellation
163
+ # and clean up (e.g., close HTTP connections).
164
+ try:
165
+ future.result(timeout=0.5)
166
+ except (FuturesTimeout, Exception):
167
+ pass
168
+ raise LatencyBudgetExceeded(
169
+ f"Call exceeded latency budget of {timeout_ms}ms. "
170
+ "The underlying call may still be running in the background."
171
+ )
172
+ except Exception as e:
173
+ if exception[0] is not None:
174
+ raise exception[0]
175
+ raise
176
+
177
+ if exception[0] is not None:
178
+ raise exception[0]
179
+
180
+ return result[0]
181
+
182
+
183
+ class TracifyClient:
184
+ REGION_HOSTS = {
185
+ "eu": "https://eu.cloud.tracify.tech",
186
+ "us": "https://us.cloud.tracify.tech",
187
+ }
188
+
189
+ def __init__(self, api_key: Optional[str] = None, host: Optional[str] = None, project_id: Optional[str] = None, region: Optional[str] = None):
190
+ self.api_key = api_key or os.environ.get("TRACIFY_API_KEY")
191
+ if not self.api_key:
192
+ raise ValueError("TRACIFY_API_KEY or TRACIFY_API_KEY must be provided or set as an environment variable")
193
+ selected_region = (region or os.environ.get("TRACIFY_REGION") or "eu").strip().lower()
194
+ if selected_region not in self.REGION_HOSTS:
195
+ raise ValueError("TRACIFY_REGION must be 'eu' or 'us'")
196
+ self.host = (host or os.environ.get("TRACIFY_HOST") or self.REGION_HOSTS[selected_region]).rstrip("/")
197
+ host_region = next((key for key, value in self.REGION_HOSTS.items() if value == self.host), None)
198
+ key_region = self._api_key_region(self.api_key)
199
+ if key_region and host_region and key_region != host_region:
200
+ raise ValueError(f"Tracify API key region mismatch: this key belongs to {key_region.upper()}, but the client is configured for {host_region.upper()}.")
201
+ self.project_id = project_id
202
+ self.ingest_url = f"{self.host}/api/ingest"
203
+ self.check_cost_url = f"{self.host}/api/orchestration/check-cost"
204
+ self._http = httpx.Client(timeout=10) if _HAS_HTTPX else None
205
+ self._last_fail_open = False
206
+ self._prompt_cache: Dict[str, Any] = {}
207
+
208
+ @staticmethod
209
+ def _api_key_region(api_key: str) -> Optional[str]:
210
+ if api_key.startswith("tracify_sk_live_eu_"):
211
+ return "eu"
212
+ if api_key.startswith("tracify_sk_live_us_"):
213
+ return "us"
214
+ if api_key.startswith("tracify_sk_live_"):
215
+ return "eu"
216
+ return None
217
+
218
+ def _send_span(self, span: Dict[str, Any]):
219
+ try:
220
+ headers = {
221
+ "Authorization": f"Bearer {self.api_key}",
222
+ "Content-Type": "application/json"
223
+ }
224
+ if self._http:
225
+ self._http.post(self.ingest_url, headers=headers, json=span)
226
+ else:
227
+ _requests_fallback.post(self.ingest_url, headers=headers, json=span, timeout=2)
228
+ except Exception as e:
229
+ print(f"Tracify Warning: Failed to ingest span: {e}")
230
+
231
+ def ingest(self, **kwargs):
232
+ span = {
233
+ "spanId": str(uuid.uuid4()),
234
+ "createdAt": datetime.utcnow().isoformat() + "Z",
235
+ "metadata": {},
236
+ **kwargs
237
+ }
238
+ self._send_span(span)
239
+
240
+ def get_prompt(self, name: str, environment: str = "production", cache_ttl_seconds: float = 60, fallback: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
241
+ """Resolve a deployed prompt, using a short-lived cache and stale/fallback recovery."""
242
+ key = f"{name}:{environment}"
243
+ now = time.time()
244
+ cached = self._prompt_cache.get(key)
245
+ if cached and cached["expires_at"] > now:
246
+ return cached["value"]
247
+ try:
248
+ response = (self._http.get if self._http else _requests_fallback.get)(f"{self.host}/api/prompts/{name}", headers={"Authorization": f"Bearer {self.api_key}"}, params={"environment": environment}, timeout=10)
249
+ if response.status_code >= 400:
250
+ raise RuntimeError(f"Prompt resolution failed ({response.status_code}): {response.text[:300]}")
251
+ value = response.json()
252
+ self._prompt_cache[key] = {"value": value, "expires_at": now + cache_ttl_seconds}
253
+ return value
254
+ except Exception:
255
+ if cached:
256
+ return cached["value"]
257
+ if fallback is not None:
258
+ return fallback
259
+ raise
260
+
261
+ def _send_feedback(self, payload: Dict[str, Any]):
262
+ if not self.project_id:
263
+ print("Tracify Warning: project_id is required for feedback and score helpers")
264
+ return
265
+ try:
266
+ headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
267
+ if self._http:
268
+ self._http.post(f"{self.host}/api/feedback", headers=headers, json={"projectId": self.project_id, **payload}, timeout=5)
269
+ else:
270
+ _requests_fallback.post(f"{self.host}/api/feedback", headers=headers, json={"projectId": self.project_id, **payload}, timeout=5)
271
+ except Exception as e:
272
+ print(f"Tracify Warning: Failed to record evaluation signal: {e}")
273
+
274
+ def feedback(self, trace_id: str, value: Union[bool, float, int, str], kind: Optional[str] = None, span_id: Optional[str] = None, reason: Optional[str] = None, comment: Optional[str] = None, end_user_id: Optional[str] = None, dedupe_key: Optional[str] = None):
275
+ inferred_kind = kind or ("star" if isinstance(value, (int, float)) and not isinstance(value, bool) else "thumb" if isinstance(value, bool) else "text")
276
+ self._send_feedback({"traceId": trace_id, "spanId": span_id, "kind": inferred_kind, "value": value, "reason": reason, "comment": comment, "endUserId": end_user_id, "dedupeKey": dedupe_key})
277
+
278
+ def score(self, trace_id: str, name: str, value: Union[bool, float, int, str], data_type: Optional[str] = None, span_id: Optional[str] = None, comment: Optional[str] = None):
279
+ self._send_feedback({"traceId": trace_id, "spanId": span_id, "kind": "text", "name": name, "value": value, "dataType": data_type, "comment": comment})
280
+
281
+ def _check_cost(self, run_id: str, increment_usd: float) -> Dict[str, Any]:
282
+ """Server-side cost check. Returns the server's decision."""
283
+ try:
284
+ headers = {
285
+ "Authorization": f"Bearer {self.api_key}",
286
+ "Content-Type": "application/json"
287
+ }
288
+ if self._http:
289
+ resp = self._http.post(
290
+ self.check_cost_url,
291
+ headers=headers,
292
+ json={"runId": run_id, "incrementUsd": increment_usd},
293
+ )
294
+ else:
295
+ resp = _requests_fallback.post(
296
+ self.check_cost_url,
297
+ headers=headers,
298
+ json={"runId": run_id, "incrementUsd": increment_usd},
299
+ timeout=5,
300
+ )
301
+ if resp.status_code != 200:
302
+ self._last_fail_open = True
303
+ return {"allowed": True, "reason": "check_failed"}
304
+ self._last_fail_open = False
305
+ return resp.json()
306
+ except Exception:
307
+ self._last_fail_open = True
308
+ return {"allowed": True, "reason": "network_error"}
309
+
310
+ def orchestrate(
311
+ self,
312
+ policy: RuntimePolicy,
313
+ call: Callable[[str, CancellationToken], Any],
314
+ model: str,
315
+ input_data: Any = None,
316
+ calculate_cost: Optional[Callable[[Any, str], float]] = None,
317
+ run_id: Optional[str] = None,
318
+ ) -> Any:
319
+ run_id = run_id or os.environ.get("TRACIFY_CURRENT_RUN_ID") or os.environ.get("TRACIFY_CURRENT_RUN_ID") or str(uuid.uuid4())
320
+ all_models = [model] + [m for m in policy.fallback_chain if m != model]
321
+ last_error: Optional[Exception] = None
322
+
323
+ for model_index, current_model in enumerate(all_models):
324
+ is_fallback = model_index > 0
325
+ fallback_reason = None
326
+ if is_fallback:
327
+ if last_error and _is_latency_abort(last_error):
328
+ fallback_reason = "latency_budget"
329
+ elif last_error:
330
+ fallback_reason = "provider_error"
331
+ else:
332
+ fallback_reason = "cost_ceiling"
333
+
334
+ max_attempts = 1 if is_fallback else (policy.max_attempts + 1)
335
+
336
+ for attempt in range(1, max_attempts + 1):
337
+ span_id = str(uuid.uuid4())
338
+ start_time = time.time()
339
+
340
+ # Server-side cost ceiling enforcement
341
+ if policy.enforcement_mode == "enforce":
342
+ estimated_cost = 0.01 if calculate_cost else 0
343
+
344
+ if policy.max_cost_per_run is not None:
345
+ check = self._check_cost(run_id, estimated_cost)
346
+ if not check.get("allowed") and check.get("reason") == "maxCostPerRun":
347
+ self.ingest(
348
+ spanId=span_id,
349
+ runId=run_id,
350
+ spanType="llm_call",
351
+ input=json.dumps(input_data) if input_data is not None else "",
352
+ output=json.dumps({"blocked": True, "reason": "cost_ceiling"}),
353
+ modelId=current_model,
354
+ costUsd=0,
355
+ latencyMs=0,
356
+ metadata={
357
+ "orchestrationBlocked": True,
358
+ "orchestrationReason": "cost_ceiling",
359
+ "orchestrationAttempt": attempt,
360
+ "orchestrationModel": current_model,
361
+ "orchestrationOriginalModel": model,
362
+ "orchestrationServerCost": check.get("currentCost"),
363
+ "orchestrationCeiling": check.get("ceiling"),
364
+ "orchestrationFailOpen": self._last_fail_open,
365
+ "orchestrationPolicy": json.dumps(policy.to_dict()),
366
+ },
367
+ )
368
+ if model_index < len(all_models) - 1:
369
+ last_error = Exception(f"Cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
370
+ break
371
+ raise Exception(f"Cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
372
+
373
+ if policy.max_cost_per_day is not None:
374
+ check = self._check_cost(run_id, estimated_cost)
375
+ if not check.get("allowed") and check.get("reason") == "maxCostPerDay":
376
+ self.ingest(
377
+ spanId=span_id,
378
+ runId=run_id,
379
+ spanType="llm_call",
380
+ input=json.dumps(input_data) if input_data is not None else "",
381
+ output=json.dumps({"blocked": True, "reason": "daily_cost_ceiling"}),
382
+ modelId=current_model,
383
+ costUsd=0,
384
+ latencyMs=0,
385
+ metadata={
386
+ "orchestrationBlocked": True,
387
+ "orchestrationReason": "daily_cost_ceiling",
388
+ "orchestrationAttempt": attempt,
389
+ "orchestrationModel": current_model,
390
+ "orchestrationOriginalModel": model,
391
+ "orchestrationServerCost": check.get("currentCost"),
392
+ "orchestrationCeiling": check.get("ceiling"),
393
+ "orchestrationFailOpen": self._last_fail_open,
394
+ "orchestrationPolicy": json.dumps(policy.to_dict()),
395
+ },
396
+ )
397
+ if model_index < len(all_models) - 1:
398
+ last_error = Exception(f"Daily cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
399
+ break
400
+ raise Exception(f"Daily cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
401
+
402
+ # Latency budget enforcement via cooperative cancellation
403
+ try:
404
+ timeout = policy.latency_budget_ms if policy.enforcement_mode == "enforce" else None
405
+ result = _run_with_timeout(
406
+ lambda token, m=current_model: call(m, token),
407
+ timeout,
408
+ )
409
+ latency_ms = int((time.time() - start_time) * 1000)
410
+ cost_usd = calculate_cost(result, current_model) if calculate_cost else 0
411
+
412
+ # Report actual cost to server
413
+ if policy.enforcement_mode == "enforce" and cost_usd > 0:
414
+ self._check_cost(run_id, cost_usd)
415
+
416
+ self.ingest(
417
+ spanId=span_id,
418
+ runId=run_id,
419
+ spanType="llm_call",
420
+ input=json.dumps(input_data) if input_data is not None else "",
421
+ output=json.dumps(result) if not isinstance(result, str) else result,
422
+ modelId=current_model,
423
+ costUsd=cost_usd,
424
+ latencyMs=latency_ms,
425
+ metadata={
426
+ "orchestrationAttempt": attempt,
427
+ "orchestrationModel": current_model,
428
+ "orchestrationOriginalModel": model,
429
+ "orchestrationIsFallback": is_fallback,
430
+ "orchestrationFallbackReason": fallback_reason,
431
+ "orchestrationFinal": True,
432
+ "orchestrationFailOpen": self._last_fail_open,
433
+ "orchestrationPolicy": json.dumps(policy.to_dict()),
434
+ },
435
+ )
436
+ return result
437
+
438
+ except Exception as error:
439
+ latency_ms = int((time.time() - start_time) * 1000)
440
+ last_error = error
441
+
442
+ retryable = _is_retryable_error(error, policy.retryable_errors)
443
+ is_last_attempt = attempt == max_attempts
444
+ was_latency_abort = _is_latency_abort(error)
445
+
446
+ self.ingest(
447
+ spanId=span_id,
448
+ runId=run_id,
449
+ spanType="llm_call" if (retryable and not is_last_attempt) else "error",
450
+ input=json.dumps(input_data) if input_data is not None else "",
451
+ output=str(error),
452
+ modelId=current_model,
453
+ costUsd=0,
454
+ latencyMs=latency_ms,
455
+ metadata={
456
+ "orchestrationAttempt": attempt,
457
+ "orchestrationModel": current_model,
458
+ "orchestrationOriginalModel": model,
459
+ "orchestrationIsFallback": is_fallback,
460
+ "orchestrationFallbackReason": fallback_reason,
461
+ "orchestrationError": True,
462
+ "orchestrationLatencyAbort": was_latency_abort,
463
+ "orchestrationRetryable": retryable,
464
+ "orchestrationWillRetry": retryable and not is_last_attempt,
465
+ "orchestrationFailOpen": self._last_fail_open,
466
+ "orchestrationPolicy": json.dumps(policy.to_dict()),
467
+ },
468
+ )
469
+
470
+ if retryable and not is_last_attempt:
471
+ delay = policy.backoff_ms * (policy.backoff_multiplier ** (attempt - 1))
472
+ time.sleep(delay / 1000)
473
+ continue
474
+
475
+ break
476
+
477
+ raise last_error or Exception("All fallback models exhausted")
478
+
479
+
480
+ def trace_agent(client: Optional[TracifyClient] = None):
481
+ """
482
+ Decorator to wrap an agent's main entry point.
483
+ Automatically handles runId generation and run_end span.
484
+ """
485
+ def decorator(func: Callable):
486
+ @functools.wraps(func)
487
+ async def async_wrapper(*args, **kwargs):
488
+ nonlocal client
489
+ if client is None:
490
+ client = TracifyClient()
491
+
492
+ run_id = str(uuid.uuid4())
493
+ start_time = time.time()
494
+
495
+ os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
496
+ os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
497
+
498
+ try:
499
+ result = await func(*args, **kwargs)
500
+ latency_ms = int((time.time() - start_time) * 1000)
501
+ client.ingest(
502
+ runId=run_id,
503
+ spanType="run_end",
504
+ output=json.dumps(result) if result else "",
505
+ latencyMs=latency_ms,
506
+ costUsd=0,
507
+ )
508
+ return result
509
+ except Exception as e:
510
+ latency_ms = int((time.time() - start_time) * 1000)
511
+ client.ingest(
512
+ runId=run_id,
513
+ spanType="error",
514
+ output=str(e),
515
+ latencyMs=latency_ms,
516
+ costUsd=0,
517
+ )
518
+ raise e
519
+
520
+ @functools.wraps(func)
521
+ def sync_wrapper(*args, **kwargs):
522
+ nonlocal client
523
+ if client is None:
524
+ client = TracifyClient()
525
+
526
+ run_id = str(uuid.uuid4())
527
+ start_time = time.time()
528
+ os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
529
+ os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
530
+
531
+ try:
532
+ result = func(*args, **kwargs)
533
+ latency_ms = int((time.time() - start_time) * 1000)
534
+ client.ingest(
535
+ runId=run_id,
536
+ spanType="run_end",
537
+ output=json.dumps(result) if result else "",
538
+ latencyMs=latency_ms,
539
+ costUsd=0,
540
+ )
541
+ return result
542
+ except Exception as e:
543
+ latency_ms = int((time.time() - start_time) * 1000)
544
+ client.ingest(
545
+ runId=run_id,
546
+ spanType="error",
547
+ output=str(e),
548
+ latencyMs=latency_ms,
549
+ costUsd=0,
550
+ )
551
+ raise e
552
+
553
+ if asyncio.iscoroutinefunction(func):
554
+ return async_wrapper
555
+ return sync_wrapper
556
+ return decorator
557
+
558
+
559
+ def llm_call(input_data: Any, output_data: Any, model_id: str, cost_usd: float, latency_ms: int, client: Optional[TracifyClient] = None, session_id: Optional[str] = None, end_user_id: Optional[str] = None, environment: Optional[str] = None, release: Optional[str] = None, tags: Optional[List[str]] = None, trace_name: Optional[str] = None):
560
+ if client is None: client = TracifyClient()
561
+ client.ingest(
562
+ runId=os.environ.get("TRACIFY_CURRENT_RUN_ID") or os.environ.get("TRACIFY_CURRENT_RUN_ID", "unknown"),
563
+ spanType="llm_call",
564
+ input=json.dumps(input_data) if not isinstance(input_data, str) else input_data,
565
+ output=json.dumps(output_data) if not isinstance(output_data, str) else output_data,
566
+ modelId=model_id,
567
+ costUsd=cost_usd,
568
+ latencyMs=latency_ms,
569
+ sessionId=session_id or "",
570
+ endUserId=end_user_id or "",
571
+ environment=environment or "",
572
+ release=release or "",
573
+ tags=tags or [],
574
+ traceName=trace_name or "",
575
+ )
576
+
577
+
578
+ def tool_call(tool_name: str, input_data: Any, output_data: Any, latency_ms: int, client: Optional[TracifyClient] = None, session_id: Optional[str] = None, end_user_id: Optional[str] = None, environment: Optional[str] = None, release: Optional[str] = None, tags: Optional[List[str]] = None, trace_name: Optional[str] = None):
579
+ if client is None: client = TracifyClient()
580
+ client.ingest(
581
+ runId=os.environ.get("TRACIFY_CURRENT_RUN_ID") or os.environ.get("TRACIFY_CURRENT_RUN_ID", "unknown"),
582
+ spanType="tool_call",
583
+ toolName=tool_name,
584
+ input=json.dumps(input_data) if not isinstance(input_data, str) else input_data,
585
+ output=json.dumps(output_data) if not isinstance(output_data, str) else output_data,
586
+ latencyMs=latency_ms,
587
+ costUsd=0,
588
+ sessionId=session_id or "",
589
+ endUserId=end_user_id or "",
590
+ environment=environment or "",
591
+ release=release or "",
592
+ tags=tags or [],
593
+ traceName=trace_name or "",
594
+ )
595
+
596
+
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracify-sdk
3
+ Version: 0.2.0
4
+ Summary: Observability, runtime enforcement, and tracing for AI agents.
5
+ Author-email: Tracify Team <hello@tracify.tech>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://tracify.tech
8
+ Project-URL: Documentation, https://docs.tracify.tech
9
+ Project-URL: Bug Tracker, https://github.com/tracify/python-sdk/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.7
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: requests>=2.25.0
15
+ Requires-Dist: httpx>=0.24.0
16
+
17
+ # Tracify Python SDK
18
+
19
+ Observability and tracing for AI agents. Gain full visibility into your agent's decision-making process, tool usage, and LLM costs.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install tracify-sdk
25
+ ```
26
+
27
+ ## Quickstart
28
+
29
+ 1. Get your API Key from the [Tracify Dashboard](https://tracify.tech).
30
+ 2. Set it as an environment variable:
31
+
32
+ ```bash
33
+ export TRACIFY_API_KEY=your_sk_live_...
34
+ ```
35
+
36
+ 3. Instrument your agent:
37
+
38
+ ```python
39
+ from tracify import trace_agent, llm_call, tool_call
40
+
41
+ @trace_agent()
42
+ async def my_agent(query: str):
43
+ # LLM Call
44
+ llm_call(
45
+ input_data=query,
46
+ output_data="Thinking...",
47
+ model_id="gpt-4o",
48
+ cost_usd=0.002,
49
+ latency_ms=800
50
+ )
51
+
52
+ # Tool Use
53
+ tool_call(
54
+ tool_name="web_search",
55
+ input_data={"q": query},
56
+ output_data="Found results...",
57
+ latency_ms=1200
58
+ )
59
+
60
+ return "Final Answer"
61
+ ```
62
+
63
+ ## Features
64
+
65
+ - **@trace_agent Decorator:** Automatically captures run lifecycle and errors.
66
+ - **Granular Spans:** Manually record LLMs, tools, and custom decisions.
67
+ - **Cost Tracking:** Monitor your infrastructure spend in real-time.
68
+ - **Asynchronous & Synchronous Support:** Works with `async/await` or standard functions.
69
+ - **Session context:** Attach `session_id`, `end_user_id`, `environment`, `release`, and `tags` to connect related traces.
70
+
71
+ ## Feedback and evaluation scores
72
+
73
+ Pass `project_id` when constructing the client to attach end-user feedback and typed scores to traces:
74
+
75
+ ```python
76
+ client = TracifyClient(project_id="your-project-id")
77
+ client.feedback(run_id, True, kind="thumb", comment="Helpful answer")
78
+ client.score(run_id, "answer_quality", 0.92, data_type="numeric")
79
+ ```
80
+
81
+ Feedback and scores use the same API key as ingestion and do not interrupt the agent when delivery fails.
82
+
83
+ ## Prompt deployment
84
+
85
+ Resolve a deployed prompt without shipping a new application build:
86
+
87
+ ```python
88
+ deployed = client.get_prompt("support-agent", "production")
89
+ client.ingest(spanType="llm", input=question, output=answer, promptVersionId=deployed["version"]["id"])
90
+ ```
91
+
92
+ Prompt responses are cached for 60 seconds by default. Use `cache_ttl_seconds=0` or provide a `fallback` for development and guaranteed-availability behavior.
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,5 @@
1
+ tracify/__init__.py,sha256=2dWTQH4lODlwHDeOJzp_dbLMw_ZbNJkIGg4fW0jpkDc,26672
2
+ tracify_sdk-0.2.0.dist-info/METADATA,sha256=pgign8knE9eJ0nSW06dgsGe-Fk5rtfnJ5aC9ku5WJUc,2834
3
+ tracify_sdk-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ tracify_sdk-0.2.0.dist-info/top_level.txt,sha256=lYYW1kNrFaWyvmH6qxknVvTJrAPtHoj3b6yMz0zNGB4,8
5
+ tracify_sdk-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tracify