trodo-python 1.0.1__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/client.py CHANGED
@@ -2,21 +2,36 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Dict, List, Optional, Union
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.people_manager import PeopleManager
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
- IdentifyResult,
18
32
  ResetResult,
19
33
  WalletAddressResult,
34
+ FeedbackProps,
20
35
  )
21
36
 
22
37
 
@@ -33,6 +48,7 @@ class TrodoClient:
33
48
  auto_events: bool = False,
34
49
  on_error: Optional[Any] = None,
35
50
  debug: bool = False,
51
+ auto_instrument: bool = True,
36
52
  ) -> None:
37
53
  if not site_id:
38
54
  raise ValueError("trodo-python: site_id is required")
@@ -69,6 +85,10 @@ class TrodoClient:
69
85
 
70
86
  self._user_context_cache: Dict[str, UserContext] = {}
71
87
 
88
+ self._span_processor = TrodoSpanProcessor(http_client=self._http)
89
+ if auto_instrument:
90
+ enable_auto_instrument(self._span_processor)
91
+
72
92
  # --------------------------------------------------------------------------
73
93
  # Primary pattern: for_user()
74
94
  # --------------------------------------------------------------------------
@@ -107,8 +127,12 @@ class TrodoClient:
107
127
  ) -> None:
108
128
  self.for_user(distinct_id).track(event_name, properties, category)
109
129
 
110
- def identify(self, distinct_id: str, identify_id: str) -> IdentifyResult:
111
- return self.for_user(distinct_id).identify(identify_id)
130
+ def identify(self, identify_id: str, session_id: Optional[str] = None) -> "UserContext":
131
+ if identify_id in self._user_context_cache:
132
+ return self._user_context_cache[identify_id]
133
+ ctx = self.for_user(identify_id, session_id)
134
+ ctx.identify(identify_id)
135
+ return ctx
112
136
 
113
137
  def wallet_address(self, distinct_id: str, wallet_addr: str) -> WalletAddressResult:
114
138
  return self.for_user(distinct_id).wallet_address(wallet_addr)
@@ -187,9 +211,189 @@ class TrodoClient:
187
211
  def flush(self) -> None:
188
212
  if self._batch_flusher:
189
213
  self._batch_flusher.flush()
214
+ self._span_processor.force_flush()
190
215
 
191
216
  def shutdown(self) -> None:
192
217
  self._auto_event_manager.disable()
193
218
  if self._batch_flusher:
194
219
  self._batch_flusher.stop()
195
220
  self._batch_flusher.flush()
221
+ self._span_processor.shutdown()
222
+
223
+ # --------------------------------------------------------------------------
224
+ # Agent Runs — Lemma-style seamless wrapping
225
+ # --------------------------------------------------------------------------
226
+
227
+ def wrap_agent(
228
+ self,
229
+ agent_name: str,
230
+ distinct_id: Optional[str] = None,
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,
248
+ )
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,
312
+ )
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,
349
+ )
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,
399
+ )
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
trodo/otel/context.py ADDED
@@ -0,0 +1,44 @@
1
+ """Active run/span context using contextvars — auto-propagates across awaits
2
+ and threading.local-style sync code.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextvars
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class ActiveSpanContext:
14
+ run_id: str
15
+ span_id: str
16
+ parent_span_id: Optional[str]
17
+ team_site_id: str
18
+ processor: object # TrodoSpanProcessor — avoid circular import
19
+
20
+
21
+ _active: contextvars.ContextVar[Optional[ActiveSpanContext]] = contextvars.ContextVar(
22
+ "trodo_active_span", default=None,
23
+ )
24
+
25
+
26
+ def get_active_context() -> Optional[ActiveSpanContext]:
27
+ return _active.get()
28
+
29
+
30
+ class run_with_context:
31
+ """Context manager that sets the active span context for its duration."""
32
+
33
+ def __init__(self, ctx: ActiveSpanContext) -> None:
34
+ self._ctx = ctx
35
+ self._token: Optional[contextvars.Token] = None
36
+
37
+ def __enter__(self) -> ActiveSpanContext:
38
+ self._token = _active.set(self._ctx)
39
+ return self._ctx
40
+
41
+ def __exit__(self, exc_type, exc, tb) -> None:
42
+ if self._token is not None:
43
+ _active.reset(self._token)
44
+ self._token = None