trodo-python 1.2.0__py3-none-any.whl → 2.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.
- trodo/__init__.py +300 -47
- trodo/api/endpoints.py +5 -1
- trodo/api/http_client.py +30 -2
- trodo/client.py +193 -112
- trodo/otel/__init__.py +21 -0
- trodo/otel/auto_instrument.py +281 -0
- trodo/otel/context.py +44 -0
- trodo/otel/helpers.py +407 -0
- trodo/otel/processor.py +165 -0
- trodo/otel/wrap_agent.py +438 -0
- trodo/types.py +12 -68
- {trodo_python-1.2.0.dist-info → trodo_python-2.1.0.dist-info}/METADATA +155 -3
- {trodo_python-1.2.0.dist-info → trodo_python-2.1.0.dist-info}/RECORD +15 -9
- {trodo_python-1.2.0.dist-info → trodo_python-2.1.0.dist-info}/WHEEL +0 -0
- {trodo_python-1.2.0.dist-info → trodo_python-2.1.0.dist-info}/top_level.txt +0 -0
trodo/client.py
CHANGED
|
@@ -2,24 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
6
7
|
|
|
7
8
|
from .api.http_client import HttpClient
|
|
8
9
|
from .session.session_manager import SessionManager
|
|
9
|
-
from .managers.
|
|
10
|
-
from .managers.group_manager import GroupManager, GroupProfile
|
|
10
|
+
from .managers.group_manager import GroupProfile
|
|
11
11
|
from .queue.event_queue import EventQueue
|
|
12
12
|
from .queue.batch_flusher import BatchFlusher
|
|
13
13
|
from .auto.auto_event_manager import AutoEventManager
|
|
14
14
|
from .user_context import UserContext
|
|
15
|
+
from .otel.processor import TrodoSpanProcessor
|
|
16
|
+
from .otel.wrap_agent import (
|
|
17
|
+
wrap_agent as wrap_agent_ctx,
|
|
18
|
+
span as span_ctx,
|
|
19
|
+
join_run as join_run_ctx,
|
|
20
|
+
current_run_id as _current_run_id,
|
|
21
|
+
current_span_id as _current_span_id,
|
|
22
|
+
)
|
|
23
|
+
from .otel.auto_instrument import enable_auto_instrument
|
|
24
|
+
from .otel.helpers import (
|
|
25
|
+
tool as tool_decorator,
|
|
26
|
+
track_llm_call as track_llm_call_fn,
|
|
27
|
+
fastapi_middleware as fastapi_middleware_fn,
|
|
28
|
+
propagation_headers as propagation_headers_fn,
|
|
29
|
+
)
|
|
15
30
|
from .types import (
|
|
16
31
|
ApiResult,
|
|
17
32
|
ResetResult,
|
|
18
33
|
WalletAddressResult,
|
|
19
|
-
AgentCallProps,
|
|
20
|
-
ToolUseProps,
|
|
21
|
-
AgentResponseProps,
|
|
22
|
-
AgentErrorProps,
|
|
23
34
|
FeedbackProps,
|
|
24
35
|
)
|
|
25
36
|
|
|
@@ -37,6 +48,7 @@ class TrodoClient:
|
|
|
37
48
|
auto_events: bool = False,
|
|
38
49
|
on_error: Optional[Any] = None,
|
|
39
50
|
debug: bool = False,
|
|
51
|
+
auto_instrument: bool = True,
|
|
40
52
|
) -> None:
|
|
41
53
|
if not site_id:
|
|
42
54
|
raise ValueError("trodo-python: site_id is required")
|
|
@@ -73,6 +85,10 @@ class TrodoClient:
|
|
|
73
85
|
|
|
74
86
|
self._user_context_cache: Dict[str, UserContext] = {}
|
|
75
87
|
|
|
88
|
+
self._span_processor = TrodoSpanProcessor(http_client=self._http)
|
|
89
|
+
if auto_instrument:
|
|
90
|
+
enable_auto_instrument(self._span_processor)
|
|
91
|
+
|
|
76
92
|
# --------------------------------------------------------------------------
|
|
77
93
|
# Primary pattern: for_user()
|
|
78
94
|
# --------------------------------------------------------------------------
|
|
@@ -195,124 +211,189 @@ class TrodoClient:
|
|
|
195
211
|
def flush(self) -> None:
|
|
196
212
|
if self._batch_flusher:
|
|
197
213
|
self._batch_flusher.flush()
|
|
214
|
+
self._span_processor.force_flush()
|
|
198
215
|
|
|
199
216
|
def shutdown(self) -> None:
|
|
200
217
|
self._auto_event_manager.disable()
|
|
201
218
|
if self._batch_flusher:
|
|
202
219
|
self._batch_flusher.stop()
|
|
203
220
|
self._batch_flusher.flush()
|
|
221
|
+
self._span_processor.shutdown()
|
|
204
222
|
|
|
205
223
|
# --------------------------------------------------------------------------
|
|
206
|
-
# Agent
|
|
224
|
+
# Agent Runs — Lemma-style seamless wrapping
|
|
207
225
|
# --------------------------------------------------------------------------
|
|
208
226
|
|
|
209
|
-
def
|
|
227
|
+
def wrap_agent(
|
|
210
228
|
self,
|
|
211
|
-
|
|
212
|
-
conversation_id: str,
|
|
213
|
-
message_id: str,
|
|
214
|
-
event_type: str,
|
|
229
|
+
agent_name: str,
|
|
215
230
|
distinct_id: Optional[str] = None,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
props.agent_id, props.conversation_id, props.message_id,
|
|
234
|
-
"agent_call", props.distinct_id, props.timestamp,
|
|
235
|
-
)
|
|
236
|
-
if props.prompt is not None:
|
|
237
|
-
payload["prompt"] = props.prompt
|
|
238
|
-
if props.model is not None:
|
|
239
|
-
payload["model"] = props.model
|
|
240
|
-
if props.temperature is not None:
|
|
241
|
-
payload["temperature"] = props.temperature
|
|
242
|
-
if props.system_prompt_version is not None:
|
|
243
|
-
payload["system_prompt_version"] = props.system_prompt_version
|
|
244
|
-
if props.provider is not None:
|
|
245
|
-
payload["provider"] = props.provider
|
|
246
|
-
if props.properties:
|
|
247
|
-
payload.update(props.properties)
|
|
248
|
-
self._http.post_agent_event(payload)
|
|
249
|
-
|
|
250
|
-
def track_tool_use(self, props: ToolUseProps) -> None:
|
|
251
|
-
"""Track a tool invocation within an agent turn."""
|
|
252
|
-
payload = self._build_agent_base(
|
|
253
|
-
props.agent_id, props.conversation_id, props.message_id,
|
|
254
|
-
"tool_use", props.distinct_id, props.timestamp,
|
|
231
|
+
conversation_id: Optional[str] = None,
|
|
232
|
+
parent_run_id: Optional[str] = None,
|
|
233
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
234
|
+
) -> wrap_agent_ctx:
|
|
235
|
+
"""Context manager that captures the wrapped block as an agent run.
|
|
236
|
+
|
|
237
|
+
Every OTel-instrumented call (Anthropic, OpenAI, LangChain…) made
|
|
238
|
+
inside the ``with`` is auto-captured as a nested span.
|
|
239
|
+
"""
|
|
240
|
+
return wrap_agent_ctx(
|
|
241
|
+
processor=self._span_processor,
|
|
242
|
+
team_site_id=self.site_id,
|
|
243
|
+
agent_name=agent_name,
|
|
244
|
+
distinct_id=distinct_id,
|
|
245
|
+
conversation_id=conversation_id,
|
|
246
|
+
parent_run_id=parent_run_id,
|
|
247
|
+
metadata=metadata,
|
|
255
248
|
)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
249
|
+
|
|
250
|
+
def agent(
|
|
251
|
+
self,
|
|
252
|
+
agent_name: str,
|
|
253
|
+
*,
|
|
254
|
+
distinct_id: Optional[str] = None,
|
|
255
|
+
conversation_id: Optional[str] = None,
|
|
256
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
257
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
258
|
+
"""Decorator form of wrap_agent."""
|
|
259
|
+
|
|
260
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
261
|
+
@functools.wraps(fn)
|
|
262
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
263
|
+
with self.wrap_agent(
|
|
264
|
+
agent_name,
|
|
265
|
+
distinct_id=distinct_id,
|
|
266
|
+
conversation_id=conversation_id,
|
|
267
|
+
metadata=metadata,
|
|
268
|
+
) as run:
|
|
269
|
+
result = fn(*args, **kwargs)
|
|
270
|
+
run.set_output(result)
|
|
271
|
+
return result
|
|
272
|
+
return wrapper
|
|
273
|
+
|
|
274
|
+
return decorator
|
|
275
|
+
|
|
276
|
+
def span(
|
|
277
|
+
self,
|
|
278
|
+
name: str,
|
|
279
|
+
*,
|
|
280
|
+
kind: str = "generic",
|
|
281
|
+
input: Any = None,
|
|
282
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
283
|
+
) -> span_ctx:
|
|
284
|
+
"""Create a nested span inside the current run."""
|
|
285
|
+
return span_ctx(name=name, kind=kind, input=input, attributes=attributes)
|
|
286
|
+
|
|
287
|
+
def join_run(
|
|
288
|
+
self,
|
|
289
|
+
run_id: str,
|
|
290
|
+
parent_span_id: Optional[str] = None,
|
|
291
|
+
*,
|
|
292
|
+
name: str = "remote.handler",
|
|
293
|
+
kind: str = "agent",
|
|
294
|
+
input: Any = None,
|
|
295
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
296
|
+
) -> join_run_ctx:
|
|
297
|
+
"""Open a span on an existing run owned by a remote service.
|
|
298
|
+
|
|
299
|
+
Use this in downstream microservices that receive an inbound
|
|
300
|
+
request carrying ``X-Trodo-Run-Id`` / ``X-Trodo-Parent-Span-Id``.
|
|
301
|
+
Prefer :meth:`fastapi_middleware` to automate this.
|
|
302
|
+
"""
|
|
303
|
+
return join_run_ctx(
|
|
304
|
+
processor=self._span_processor,
|
|
305
|
+
team_site_id=self.site_id,
|
|
306
|
+
run_id=run_id,
|
|
307
|
+
parent_span_id=parent_span_id,
|
|
308
|
+
name=name,
|
|
309
|
+
kind=kind,
|
|
310
|
+
input=input,
|
|
311
|
+
attributes=attributes,
|
|
274
312
|
)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
313
|
+
|
|
314
|
+
def tool(
|
|
315
|
+
self,
|
|
316
|
+
name: Optional[str] = None,
|
|
317
|
+
*,
|
|
318
|
+
kind: str = "tool",
|
|
319
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
320
|
+
"""Decorator that wraps a function call as a tool span."""
|
|
321
|
+
return tool_decorator(name=name, kind=kind)
|
|
322
|
+
|
|
323
|
+
def track_llm_call(
|
|
324
|
+
self,
|
|
325
|
+
*,
|
|
326
|
+
model: Optional[str] = None,
|
|
327
|
+
provider: Optional[str] = None,
|
|
328
|
+
input_tokens: Optional[int] = None,
|
|
329
|
+
output_tokens: Optional[int] = None,
|
|
330
|
+
prompt: Any = None,
|
|
331
|
+
completion: Any = None,
|
|
332
|
+
temperature: Optional[float] = None,
|
|
333
|
+
cost: Optional[float] = None,
|
|
334
|
+
name: Optional[str] = None,
|
|
335
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
336
|
+
) -> None:
|
|
337
|
+
"""Record an LLM span for a raw-HTTP caller (no OTel adapter)."""
|
|
338
|
+
track_llm_call_fn(
|
|
339
|
+
model=model,
|
|
340
|
+
provider=provider,
|
|
341
|
+
input_tokens=input_tokens,
|
|
342
|
+
output_tokens=output_tokens,
|
|
343
|
+
prompt=prompt,
|
|
344
|
+
completion=completion,
|
|
345
|
+
temperature=temperature,
|
|
346
|
+
cost=cost,
|
|
347
|
+
name=name,
|
|
348
|
+
metadata=metadata,
|
|
296
349
|
)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
350
|
+
|
|
351
|
+
def fastapi_middleware(self) -> Callable:
|
|
352
|
+
"""Return a FastAPI/Starlette middleware that auto-joins runs."""
|
|
353
|
+
return fastapi_middleware_fn(self)
|
|
354
|
+
|
|
355
|
+
def propagation_headers(self) -> Dict[str, str]:
|
|
356
|
+
"""Return outbound HTTP headers carrying the current run/span id."""
|
|
357
|
+
return propagation_headers_fn()
|
|
358
|
+
|
|
359
|
+
def current_run_id(self) -> Optional[str]:
|
|
360
|
+
return _current_run_id()
|
|
361
|
+
|
|
362
|
+
def current_span_id(self) -> Optional[str]:
|
|
363
|
+
return _current_span_id()
|
|
364
|
+
|
|
365
|
+
def feedback(
|
|
366
|
+
self,
|
|
367
|
+
run_id: str,
|
|
368
|
+
satisfaction: Optional[str] = None,
|
|
369
|
+
rating: Optional[float] = None,
|
|
370
|
+
comment: Optional[str] = None,
|
|
371
|
+
feedback: Optional[str] = None,
|
|
372
|
+
distinct_id: Optional[str] = None,
|
|
373
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
374
|
+
) -> ApiResult:
|
|
375
|
+
"""Attach feedback to a completed run."""
|
|
376
|
+
if satisfaction is None and rating is None and not (comment or feedback):
|
|
377
|
+
raise ValueError(
|
|
378
|
+
"At least one of 'satisfaction', 'rating', or 'comment' is required"
|
|
379
|
+
)
|
|
380
|
+
payload: Dict[str, Any] = {
|
|
381
|
+
"satisfaction": satisfaction,
|
|
382
|
+
"rating": rating,
|
|
383
|
+
"comment": comment if comment is not None else feedback,
|
|
384
|
+
"distinct_id": distinct_id,
|
|
385
|
+
"attributes": metadata or {},
|
|
386
|
+
}
|
|
387
|
+
return self._http.post_run_feedback(run_id, payload)
|
|
388
|
+
|
|
389
|
+
def feedback_props(self, run_id: str, props: FeedbackProps) -> ApiResult:
|
|
390
|
+
"""Convenience — pass a FeedbackProps dataclass."""
|
|
391
|
+
return self.feedback(
|
|
392
|
+
run_id,
|
|
393
|
+
satisfaction=props.satisfaction,
|
|
394
|
+
rating=props.rating,
|
|
395
|
+
comment=props.comment,
|
|
396
|
+
feedback=props.feedback,
|
|
397
|
+
distinct_id=props.distinct_id,
|
|
398
|
+
metadata=props.metadata,
|
|
314
399
|
)
|
|
315
|
-
payload["feedback"] = props.feedback
|
|
316
|
-
if props.properties:
|
|
317
|
-
payload.update(props.properties)
|
|
318
|
-
self._http.post_agent_event(payload)
|
trodo/otel/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""OpenTelemetry-compatible run/span pipeline for trodo-python."""
|
|
2
|
+
|
|
3
|
+
from .context import ActiveSpanContext, get_active_context, run_with_context
|
|
4
|
+
from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
|
|
5
|
+
from .wrap_agent import wrap_agent, span, SpanHandle, RunHandle
|
|
6
|
+
from .auto_instrument import enable_auto_instrument, otel_span_to_trodo_span
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ActiveSpanContext",
|
|
10
|
+
"get_active_context",
|
|
11
|
+
"run_with_context",
|
|
12
|
+
"TrodoSpanProcessor",
|
|
13
|
+
"TrodoRun",
|
|
14
|
+
"TrodoSpan",
|
|
15
|
+
"wrap_agent",
|
|
16
|
+
"span",
|
|
17
|
+
"SpanHandle",
|
|
18
|
+
"RunHandle",
|
|
19
|
+
"enable_auto_instrument",
|
|
20
|
+
"otel_span_to_trodo_span",
|
|
21
|
+
]
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Optional: register OpenTelemetry auto-instrumentation that feeds spans
|
|
2
|
+
into our TrodoSpanProcessor. Call ``enable_auto_instrument()`` once at
|
|
3
|
+
startup to pick up Anthropic / OpenAI / LangChain / LlamaIndex / Google
|
|
4
|
+
Generative AI / Vertex AI and others — without any more code.
|
|
5
|
+
|
|
6
|
+
Each underlying OTel package is an optional dependency — we import them
|
|
7
|
+
lazily and skip anything that isn't installed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
14
|
+
|
|
15
|
+
from .context import get_active_context
|
|
16
|
+
from .processor import TrodoSpan, TrodoSpanProcessor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _hr_to_iso(nanos: Optional[int]) -> Optional[str]:
|
|
20
|
+
if nanos is None:
|
|
21
|
+
return None
|
|
22
|
+
return datetime.fromtimestamp(nanos / 1e9, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _infer_kind(attrs: Dict[str, Any]) -> str:
|
|
26
|
+
if not attrs:
|
|
27
|
+
return "generic"
|
|
28
|
+
if attrs.get("gen_ai.tool.name"):
|
|
29
|
+
return "tool"
|
|
30
|
+
if attrs.get("gen_ai.operation.name") or attrs.get("gen_ai.request.model"):
|
|
31
|
+
return "llm"
|
|
32
|
+
if attrs.get("db.system") or attrs.get("retrieval.query"):
|
|
33
|
+
return "retrieval"
|
|
34
|
+
return "generic"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
|
|
38
|
+
"""Translate an OTel ReadableSpan to our TrodoSpan using GenAI semconv."""
|
|
39
|
+
ctx = get_active_context()
|
|
40
|
+
if ctx is None:
|
|
41
|
+
return None # span emitted outside of wrap_agent — drop
|
|
42
|
+
try:
|
|
43
|
+
span_ctx = (
|
|
44
|
+
otel_span.get_span_context()
|
|
45
|
+
if callable(getattr(otel_span, "get_span_context", None))
|
|
46
|
+
else otel_span.context
|
|
47
|
+
)
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
span_id_int = getattr(span_ctx, "span_id", None)
|
|
52
|
+
if not span_id_int:
|
|
53
|
+
return None
|
|
54
|
+
span_id = f"{span_id_int:016x}" if isinstance(span_id_int, int) else str(span_id_int)
|
|
55
|
+
|
|
56
|
+
parent = getattr(otel_span, "parent", None)
|
|
57
|
+
parent_span_id: Optional[str] = None
|
|
58
|
+
if parent is not None:
|
|
59
|
+
pid = getattr(parent, "span_id", None)
|
|
60
|
+
parent_span_id = f"{pid:016x}" if isinstance(pid, int) else str(pid) if pid else None
|
|
61
|
+
if parent_span_id is None:
|
|
62
|
+
parent_span_id = ctx.span_id
|
|
63
|
+
|
|
64
|
+
attrs = dict(getattr(otel_span, "attributes", {}) or {})
|
|
65
|
+
kind = _infer_kind(attrs)
|
|
66
|
+
|
|
67
|
+
start_time = getattr(otel_span, "start_time", None)
|
|
68
|
+
end_time = getattr(otel_span, "end_time", None)
|
|
69
|
+
started_at = _hr_to_iso(start_time) or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
70
|
+
ended_at = _hr_to_iso(end_time)
|
|
71
|
+
duration_ms = None
|
|
72
|
+
if start_time and end_time:
|
|
73
|
+
duration_ms = max(0, int((end_time - start_time) / 1e6))
|
|
74
|
+
|
|
75
|
+
status = getattr(otel_span, "status", None)
|
|
76
|
+
status_code = getattr(status, "status_code", None)
|
|
77
|
+
ok = "ok"
|
|
78
|
+
try:
|
|
79
|
+
from opentelemetry.trace import StatusCode
|
|
80
|
+
|
|
81
|
+
ok = "error" if status_code == StatusCode.ERROR else "ok"
|
|
82
|
+
except Exception:
|
|
83
|
+
ok = "error" if (status_code and str(status_code).endswith("ERROR")) else "ok"
|
|
84
|
+
|
|
85
|
+
# Accept both stable and experimental GenAI semconv keys.
|
|
86
|
+
in_toks = (
|
|
87
|
+
attrs.get("gen_ai.usage.input_tokens")
|
|
88
|
+
or attrs.get("gen_ai.usage.prompt_tokens")
|
|
89
|
+
or attrs.get("llm.usage.prompt_tokens")
|
|
90
|
+
)
|
|
91
|
+
out_toks = (
|
|
92
|
+
attrs.get("gen_ai.usage.output_tokens")
|
|
93
|
+
or attrs.get("gen_ai.usage.completion_tokens")
|
|
94
|
+
or attrs.get("llm.usage.completion_tokens")
|
|
95
|
+
)
|
|
96
|
+
model = (
|
|
97
|
+
attrs.get("gen_ai.request.model")
|
|
98
|
+
or attrs.get("gen_ai.response.model")
|
|
99
|
+
or attrs.get("llm.request.model")
|
|
100
|
+
)
|
|
101
|
+
provider = attrs.get("gen_ai.system") or attrs.get("llm.vendor")
|
|
102
|
+
prompt = attrs.get("gen_ai.prompt") or attrs.get("llm.prompts")
|
|
103
|
+
completion = attrs.get("gen_ai.completion") or attrs.get("llm.completion")
|
|
104
|
+
|
|
105
|
+
return TrodoSpan(
|
|
106
|
+
span_id=span_id,
|
|
107
|
+
run_id=ctx.run_id,
|
|
108
|
+
parent_span_id=parent_span_id,
|
|
109
|
+
kind=kind,
|
|
110
|
+
name=getattr(otel_span, "name", kind),
|
|
111
|
+
status=ok,
|
|
112
|
+
started_at=started_at,
|
|
113
|
+
ended_at=ended_at,
|
|
114
|
+
duration_ms=duration_ms,
|
|
115
|
+
model=model,
|
|
116
|
+
provider=provider,
|
|
117
|
+
input_tokens=int(in_toks) if in_toks is not None else None,
|
|
118
|
+
output_tokens=int(out_toks) if out_toks is not None else None,
|
|
119
|
+
temperature=attrs.get("gen_ai.request.temperature"),
|
|
120
|
+
tool_name=attrs.get("gen_ai.tool.name"),
|
|
121
|
+
input=prompt,
|
|
122
|
+
output=completion,
|
|
123
|
+
attributes=attrs or None,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _OtelAdapter:
|
|
128
|
+
"""Adapter matching opentelemetry.sdk.trace.SpanProcessor interface."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, processor: TrodoSpanProcessor) -> None:
|
|
131
|
+
self._processor = processor
|
|
132
|
+
|
|
133
|
+
def on_start(self, span: Any, parent_context: Any = None) -> None:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
def on_end(self, span: Any) -> None:
|
|
137
|
+
trodo_span = otel_span_to_trodo_span(span)
|
|
138
|
+
if trodo_span is not None:
|
|
139
|
+
self._processor.enqueue_span(trodo_span)
|
|
140
|
+
|
|
141
|
+
def shutdown(self) -> None:
|
|
142
|
+
self._processor.shutdown()
|
|
143
|
+
|
|
144
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
145
|
+
self._processor.force_flush()
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_INSTRUMENTORS: List[tuple[str, Callable[[], Any]]] = []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _register_instrumentors() -> None:
|
|
153
|
+
"""Build the list of known instrumentor factories.
|
|
154
|
+
|
|
155
|
+
Each entry is ``(framework_id, factory)`` where ``factory`` performs a
|
|
156
|
+
lazy import and returns an ``instrument()`` call. Failures are swallowed
|
|
157
|
+
so missing optional deps never break user code.
|
|
158
|
+
"""
|
|
159
|
+
global _INSTRUMENTORS
|
|
160
|
+
if _INSTRUMENTORS:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
def _anthropic() -> Any:
|
|
164
|
+
from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor # type: ignore
|
|
165
|
+
|
|
166
|
+
AnthropicInstrumentor().instrument()
|
|
167
|
+
|
|
168
|
+
def _openai() -> Any:
|
|
169
|
+
from opentelemetry.instrumentation.openai import OpenAIInstrumentor # type: ignore
|
|
170
|
+
|
|
171
|
+
OpenAIInstrumentor().instrument()
|
|
172
|
+
|
|
173
|
+
def _openai_v2() -> Any:
|
|
174
|
+
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor # type: ignore
|
|
175
|
+
|
|
176
|
+
OpenAIInstrumentor().instrument()
|
|
177
|
+
|
|
178
|
+
def _langchain() -> Any:
|
|
179
|
+
from opentelemetry.instrumentation.langchain import LangChainInstrumentor # type: ignore
|
|
180
|
+
|
|
181
|
+
LangChainInstrumentor().instrument()
|
|
182
|
+
|
|
183
|
+
def _llama_index() -> Any:
|
|
184
|
+
from opentelemetry.instrumentation.llama_index import LlamaIndexInstrumentor # type: ignore
|
|
185
|
+
|
|
186
|
+
LlamaIndexInstrumentor().instrument()
|
|
187
|
+
|
|
188
|
+
def _google_generativeai() -> Any:
|
|
189
|
+
from opentelemetry.instrumentation.google_generativeai import ( # type: ignore
|
|
190
|
+
GoogleGenerativeAIInstrumentor,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
GoogleGenerativeAIInstrumentor().instrument()
|
|
194
|
+
|
|
195
|
+
def _vertexai() -> Any:
|
|
196
|
+
from opentelemetry.instrumentation.vertexai import VertexAIInstrumentor # type: ignore
|
|
197
|
+
|
|
198
|
+
VertexAIInstrumentor().instrument()
|
|
199
|
+
|
|
200
|
+
def _bedrock() -> Any:
|
|
201
|
+
from opentelemetry.instrumentation.bedrock import BedrockInstrumentor # type: ignore
|
|
202
|
+
|
|
203
|
+
BedrockInstrumentor().instrument()
|
|
204
|
+
|
|
205
|
+
def _cohere() -> Any:
|
|
206
|
+
from opentelemetry.instrumentation.cohere import CohereInstrumentor # type: ignore
|
|
207
|
+
|
|
208
|
+
CohereInstrumentor().instrument()
|
|
209
|
+
|
|
210
|
+
def _mistralai() -> Any:
|
|
211
|
+
from opentelemetry.instrumentation.mistralai import MistralAiInstrumentor # type: ignore
|
|
212
|
+
|
|
213
|
+
MistralAiInstrumentor().instrument()
|
|
214
|
+
|
|
215
|
+
def _haystack() -> Any:
|
|
216
|
+
from opentelemetry.instrumentation.haystack import HaystackInstrumentor # type: ignore
|
|
217
|
+
|
|
218
|
+
HaystackInstrumentor().instrument()
|
|
219
|
+
|
|
220
|
+
def _httpx() -> Any:
|
|
221
|
+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor # type: ignore
|
|
222
|
+
|
|
223
|
+
HTTPXClientInstrumentor().instrument()
|
|
224
|
+
|
|
225
|
+
def _requests() -> Any:
|
|
226
|
+
from opentelemetry.instrumentation.requests import RequestsInstrumentor # type: ignore
|
|
227
|
+
|
|
228
|
+
RequestsInstrumentor().instrument()
|
|
229
|
+
|
|
230
|
+
_INSTRUMENTORS = [
|
|
231
|
+
("anthropic", _anthropic),
|
|
232
|
+
("openai", _openai),
|
|
233
|
+
("openai_v2", _openai_v2),
|
|
234
|
+
("langchain", _langchain),
|
|
235
|
+
("llama_index", _llama_index),
|
|
236
|
+
("google_generativeai", _google_generativeai),
|
|
237
|
+
("vertexai", _vertexai),
|
|
238
|
+
("bedrock", _bedrock),
|
|
239
|
+
("cohere", _cohere),
|
|
240
|
+
("mistralai", _mistralai),
|
|
241
|
+
("haystack", _haystack),
|
|
242
|
+
("httpx", _httpx),
|
|
243
|
+
("requests", _requests),
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def enable_auto_instrument(
|
|
248
|
+
processor: TrodoSpanProcessor,
|
|
249
|
+
disable: Optional[Iterable[str]] = None,
|
|
250
|
+
) -> List[str]:
|
|
251
|
+
"""Register OTel auto-instrumentations for installed packages.
|
|
252
|
+
|
|
253
|
+
Returns the list of framework ids that were actually instrumented.
|
|
254
|
+
Skipped silently if opentelemetry-sdk or the per-framework
|
|
255
|
+
instrumentation package isn't installed.
|
|
256
|
+
"""
|
|
257
|
+
disabled = set(disable or [])
|
|
258
|
+
try:
|
|
259
|
+
from opentelemetry import trace # type: ignore
|
|
260
|
+
from opentelemetry.sdk.trace import TracerProvider # type: ignore
|
|
261
|
+
except Exception:
|
|
262
|
+
return []
|
|
263
|
+
|
|
264
|
+
provider = trace.get_tracer_provider()
|
|
265
|
+
if not isinstance(provider, TracerProvider):
|
|
266
|
+
provider = TracerProvider()
|
|
267
|
+
trace.set_tracer_provider(provider)
|
|
268
|
+
|
|
269
|
+
provider.add_span_processor(_OtelAdapter(processor))
|
|
270
|
+
|
|
271
|
+
_register_instrumentors()
|
|
272
|
+
active: List[str] = []
|
|
273
|
+
for name, register in _INSTRUMENTORS:
|
|
274
|
+
if name in disabled:
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
register()
|
|
278
|
+
active.append(name)
|
|
279
|
+
except Exception:
|
|
280
|
+
continue
|
|
281
|
+
return active
|