trodo-python 2.10.3__tar.gz → 2.10.5__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.
Files changed (47) hide show
  1. {trodo_python-2.10.3 → trodo_python-2.10.5}/PKG-INFO +1 -1
  2. {trodo_python-2.10.3 → trodo_python-2.10.5}/pyproject.toml +1 -1
  3. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/__init__.py +1 -1
  4. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/client.py +9 -1
  5. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/otel/auto_instrument.py +40 -3
  6. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/otel/register.py +44 -2
  7. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/otel/wrap_agent.py +87 -2
  8. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo_python.egg-info/PKG-INFO +1 -1
  9. {trodo_python-2.10.3 → trodo_python-2.10.5}/README.md +0 -0
  10. {trodo_python-2.10.3 → trodo_python-2.10.5}/setup.cfg +0 -0
  11. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_anon_distinct_id 2.py +0 -0
  12. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_anon_distinct_id.py +0 -0
  13. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_auto_instrument_fixes.py +0 -0
  14. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_cross_process_session.py +0 -0
  15. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_end_run.py +0 -0
  16. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_error_enrichment.py +0 -0
  17. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_llm_usage_cost.py +0 -0
  18. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_processor_methods.py +0 -0
  19. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_register_otel.py +0 -0
  20. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_start_run.py +0 -0
  21. {trodo_python-2.10.3 → trodo_python-2.10.5}/tests/test_wrap_agent_unchanged.py +0 -0
  22. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/api/__init__.py +0 -0
  23. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/api/async_client.py +0 -0
  24. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/api/endpoints.py +0 -0
  25. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/api/http_client.py +0 -0
  26. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/auto/__init__.py +0 -0
  27. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/auto/auto_event_manager.py +0 -0
  28. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/managers/__init__.py +0 -0
  29. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/managers/group_manager.py +0 -0
  30. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/managers/people_manager.py +0 -0
  31. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/otel/__init__.py +0 -0
  32. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/otel/context.py +0 -0
  33. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/otel/helpers.py +0 -0
  34. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/otel/processor.py +0 -0
  35. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/otel/transport.py +0 -0
  36. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/queue/__init__.py +0 -0
  37. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/queue/batch_flusher.py +0 -0
  38. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/queue/event_queue.py +0 -0
  39. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/session/__init__.py +0 -0
  40. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/session/server_session.py +0 -0
  41. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/session/session_manager.py +0 -0
  42. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/types.py +0 -0
  43. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo/user_context.py +0 -0
  44. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo_python.egg-info/SOURCES.txt +0 -0
  45. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo_python.egg-info/dependency_links.txt +0 -0
  46. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo_python.egg-info/requires.txt +0 -0
  47. {trodo_python-2.10.3 → trodo_python-2.10.5}/trodo_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.10.3
3
+ Version: 2.10.5
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.10.3"
7
+ version = "2.10.5"
8
8
  description = "Trodo Analytics SDK for Python — server-side event tracking"
9
9
  readme = "README.md"
10
10
  license = { text = "ISC" }
@@ -40,7 +40,7 @@ Downstream microservice (join the caller's run instead of making a new one):
40
40
 
41
41
  from __future__ import annotations
42
42
 
43
- __version__ = "2.10.3"
43
+ __version__ = "2.10.5"
44
44
 
45
45
  from typing import Any, Callable, Dict, List, Optional, Union
46
46
 
@@ -23,7 +23,11 @@ from .otel.wrap_agent import (
23
23
  current_span_id as _current_span_id,
24
24
  )
25
25
  from .otel.auto_instrument import enable_auto_instrument
26
- from .otel.register import register_otel as _register_otel
26
+ from .otel.register import (
27
+ register_otel as _register_otel,
28
+ flush_otlp_pipeline as _flush_otlp_pipeline,
29
+ shutdown_otlp_pipeline as _shutdown_otlp_pipeline,
30
+ )
27
31
  from .otel.helpers import (
28
32
  tool as tool_decorator,
29
33
  track_llm_call as track_llm_call_fn,
@@ -233,6 +237,9 @@ class TrodoClient:
233
237
  if self._batch_flusher:
234
238
  self._batch_flusher.flush()
235
239
  self._span_processor.force_flush()
240
+ # 'otlp' mode: drain the OTel BatchSpanProcessor too — it batches for
241
+ # up to 5s, so without this a short-lived process drops its spans.
242
+ _flush_otlp_pipeline()
236
243
 
237
244
  def shutdown(self) -> None:
238
245
  self._auto_event_manager.disable()
@@ -240,6 +247,7 @@ class TrodoClient:
240
247
  self._batch_flusher.stop()
241
248
  self._batch_flusher.flush()
242
249
  self._span_processor.shutdown()
250
+ _shutdown_otlp_pipeline()
243
251
 
244
252
  # --------------------------------------------------------------------------
245
253
  # Agent Runs — Lemma-style seamless wrapping
@@ -58,6 +58,24 @@ _ATTR_TRODO_RUN_ID = "trodo.run_id"
58
58
  _ATTR_TRODO_PARENT_SPAN_ID = "trodo.parent_span_id"
59
59
 
60
60
 
61
+ def _span_id_to_uuid(span_id: Optional[str]) -> Optional[str]:
62
+ """Convert an OTel hex span id into UUID format. Mirrors trodo-node's
63
+ spanIdToUuid: 16-hex OTel ids are left-zero-padded into the same UUID
64
+ address space as our uuid4-minted ids. Without this the bridge sent raw
65
+ 16-hex into agent_spans' Postgres uuid columns -> the ingest 400'd and
66
+ the WHOLE run payload (run row + every span) was silently dropped, so
67
+ Python auto-instrumented runs never landed."""
68
+ if not span_id:
69
+ return span_id
70
+ h = str(span_id).replace("-", "").lower()
71
+ if len(h) == 32:
72
+ return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
73
+ if len(h) == 16:
74
+ p = "0000000000000000" + h
75
+ return f"{p[0:8]}-{p[8:12]}-{p[12:16]}-{p[16:20]}-{p[20:32]}"
76
+ return span_id
77
+
78
+
61
79
  def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
62
80
  """Translate an OTel ReadableSpan to our TrodoSpan using GenAI semconv.
63
81
 
@@ -77,7 +95,8 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
77
95
  span_id_int = getattr(span_ctx, "span_id", None)
78
96
  if not span_id_int:
79
97
  return None
80
- span_id = f"{span_id_int:016x}" if isinstance(span_id_int, int) else str(span_id_int)
98
+ span_id_hex = f"{span_id_int:016x}" if isinstance(span_id_int, int) else str(span_id_int)
99
+ span_id = _span_id_to_uuid(span_id_hex)
81
100
 
82
101
  attrs = dict(getattr(otel_span, "attributes", {}) or {})
83
102
 
@@ -95,7 +114,7 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
95
114
  otel_parent_span_id: Optional[str] = None
96
115
  if parent is not None:
97
116
  pid = getattr(parent, "span_id", None)
98
- otel_parent_span_id = (
117
+ otel_parent_span_id = _span_id_to_uuid(
99
118
  f"{pid:016x}" if isinstance(pid, int) else str(pid) if pid else None
100
119
  )
101
120
 
@@ -201,12 +220,30 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
201
220
  )
202
221
 
203
222
 
204
- class _OtelAdapter:
223
+ # Subclass the REAL SpanProcessor when the sdk is importable (it always is on
224
+ # the path that registers the adapter — enable_auto_instrument requires it).
225
+ # Duck-typing broke on opentelemetry-sdk >= 1.43: the provider calls
226
+ # `sp._on_ending(span)` on every span end, so a processor without the base
227
+ # class raises AttributeError INSIDE USER CODE at span.end().
228
+ try: # pragma: no cover — exercised implicitly by every auto-instrument test
229
+ from opentelemetry.sdk.trace import SpanProcessor as _SpanProcessorBase
230
+ except Exception:
231
+ _SpanProcessorBase = object # type: ignore[assignment,misc]
232
+
233
+
234
+ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
205
235
  """Adapter matching opentelemetry.sdk.trace.SpanProcessor interface."""
206
236
 
207
237
  def __init__(self, processor: TrodoSpanProcessor) -> None:
238
+ try:
239
+ super().__init__()
240
+ except TypeError:
241
+ pass
208
242
  self._processor = processor
209
243
 
244
+ def on_ending(self, span: Any) -> None: # sdk >= 1.43 hook — no mutation needed
245
+ return None
246
+
210
247
  def on_start(self, span: Any, parent_context: Any = None) -> None:
211
248
  # Stamp active run/parent ids while contextvars are still alive.
212
249
  # See otel_span_to_trodo_span() docstring for the failure mode this
@@ -36,6 +36,33 @@ class RegisterOtelResult:
36
36
 
37
37
  _registered: Optional[RegisterOtelResult] = None
38
38
 
39
+ # Flush/shutdown handle for the 'otlp'-mode pipeline. Without this, the
40
+ # BatchSpanProcessor created in _setup_otlp_mode (default 5s schedule) is
41
+ # unreachable — trodo.flush()/shutdown() only drained the events batcher and
42
+ # TrodoSpanProcessor, so short-lived processes silently dropped every
43
+ # OTLP-exported span. TrodoClient.flush()/shutdown() call these.
44
+ _otlp_processor: Optional[Any] = None
45
+
46
+
47
+ def flush_otlp_pipeline() -> None:
48
+ """Force-flush the 'otlp'-mode export pipeline. No-op in 'trodo' mode."""
49
+ if _otlp_processor is not None:
50
+ try:
51
+ _otlp_processor.force_flush()
52
+ except Exception:
53
+ pass
54
+
55
+
56
+ def shutdown_otlp_pipeline() -> None:
57
+ """Shut down the 'otlp'-mode export pipeline (flushes first). No-op in 'trodo' mode."""
58
+ global _otlp_processor
59
+ if _otlp_processor is not None:
60
+ proc, _otlp_processor = _otlp_processor, None
61
+ try:
62
+ proc.shutdown()
63
+ except Exception:
64
+ pass
65
+
39
66
 
40
67
  def register_otel(
41
68
  *,
@@ -86,8 +113,9 @@ def register_otel(
86
113
 
87
114
  def _reset_for_tests() -> None:
88
115
  """Reset internal state — for tests only. Not exported from __init__.py."""
89
- global _registered
116
+ global _registered, _otlp_processor
90
117
  _registered = None
118
+ _otlp_processor = None
91
119
  _reset_transport_for_tests()
92
120
 
93
121
 
@@ -131,6 +159,11 @@ def _setup_otlp_mode(
131
159
  headers={"Authorization": f"Bearer {site_id}"},
132
160
  )
133
161
  batch_processor = BatchSpanProcessor(exporter)
162
+ # Expose flush/shutdown so trodo.flush()/shutdown() can drain the batch —
163
+ # BatchSpanProcessor holds spans for up to 5s by default, so without this
164
+ # a short-lived process exits before the exporter ever sends them.
165
+ global _otlp_processor
166
+ _otlp_processor = batch_processor
134
167
 
135
168
  # Coexistence: if a non-default TracerProvider is already registered,
136
169
  # attach our exporter to it instead of replacing it.
@@ -162,7 +195,16 @@ def _setup_otlp_mode(
162
195
  active = _build_instrumentations(disable)
163
196
 
164
197
  # Activate the OTel transport so wrap_agent / span dispatch via tracer.
165
- set_otel_transport(otel_trace.get_tracer("trodo-agent"))
198
+ # Pass the use_span/Status/StatusCode helpers (same as the coexistence
199
+ # branch above) — without use_span, wrap_agent's root span is never made
200
+ # current, so trodo.span() children and auto-instrumented spans can't
201
+ # join its trace, and error status can never be stamped.
202
+ set_otel_transport(
203
+ otel_trace.get_tracer("trodo-agent"),
204
+ use_span=use_span,
205
+ status_cls=Status,
206
+ status_code=StatusCode,
207
+ )
166
208
  return active
167
209
 
168
210
 
@@ -642,10 +642,12 @@ class wrap_agent:
642
642
  assert self._otel_span is not None
643
643
  otel_span = self._otel_span
644
644
  try:
645
+ # OTel attribute values must be primitives — dicts/lists are
646
+ # rejected (with a warning) and silently dropped. Serialize.
645
647
  if self.handle.input is not None:
646
- otel_span.set_attribute("ai.prompt", self.handle.input)
648
+ otel_span.set_attribute("ai.prompt", _serialize_attr(self.handle.input))
647
649
  if self.handle.output is not None:
648
- otel_span.set_attribute("ai.response.text", self.handle.output)
650
+ otel_span.set_attribute("ai.response.text", _serialize_attr(self.handle.output))
649
651
  for k, v in self.handle.metadata.items():
650
652
  otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
651
653
  if exc is not None:
@@ -793,8 +795,17 @@ class span:
793
795
  self._active: Optional[ActiveSpanContext] = None
794
796
  self._span_id: str = ""
795
797
  self.handle: Optional[SpanHandle] = None
798
+ # 'otlp' transport state — populated only when register_otel(mode='otlp').
799
+ self._otel_span: Optional[Any] = None
800
+ self._otel_cm: Optional[Any] = None
796
801
 
797
802
  def __enter__(self) -> SpanHandle:
803
+ # Dispatch: 'otlp' transport routes through the OTel tracer so this
804
+ # span joins the wrap_agent root's trace (mirrors Node withSpanOtel).
805
+ # Without this branch, otlp-mode children hit the "no active run"
806
+ # no-op below and were silently dropped.
807
+ if get_transport_mode() == "otlp" and get_otel_tracer() is not None:
808
+ return self._enter_otel()
798
809
  self._active = get_active_context()
799
810
  self._span_id = str(uuid.uuid4())
800
811
  self.handle = SpanHandle(self._span_id, self._name)
@@ -819,6 +830,8 @@ class span:
819
830
  return self.handle
820
831
 
821
832
  def __exit__(self, exc_type, exc, tb) -> None:
833
+ if self._otel_span is not None:
834
+ return self._exit_otel(exc_type, exc, tb)
822
835
  if self._active is None or self.handle is None:
823
836
  return None
824
837
  ended_iso = _now_iso()
@@ -860,3 +873,75 @@ class span:
860
873
  if self._ctx_mgr is not None:
861
874
  self._ctx_mgr.__exit__(exc_type, exc, tb)
862
875
  return None
876
+
877
+ # ----------------------------------------------------------------------
878
+ # 'otlp' transport — OTel tracer path (mirrors Node withSpanOtel).
879
+ # ----------------------------------------------------------------------
880
+
881
+ def _enter_otel(self) -> SpanHandle:
882
+ tracer = get_otel_tracer()
883
+ # start_span parents to the CURRENT OTel context — the wrap_agent root
884
+ # (made current via use_span in _enter_otel of wrap_agent).
885
+ otel_span = tracer.start_span(self._name)
886
+ use_span_helper, _, _ = get_otel_helpers()
887
+ if use_span_helper is not None:
888
+ self._otel_cm = use_span_helper(otel_span, end_on_exit=False)
889
+ self._otel_cm.__enter__()
890
+ self._otel_span = otel_span
891
+ self._span_id = str(uuid.uuid4())
892
+ self.handle = SpanHandle(self._span_id, self._name)
893
+ if self._input is not None:
894
+ self.handle.input = self._input
895
+ if self._attributes:
896
+ self.handle.attributes.update(self._attributes)
897
+ return self.handle
898
+
899
+ def _exit_otel(self, exc_type, exc, tb) -> None:
900
+ otel_span = self._otel_span
901
+ h = self.handle
902
+ try:
903
+ # Stamp Trodo kind via gen_ai.* / retrieval.* convention so the
904
+ # backend's kind inference picks it up (same keys as trodo-node).
905
+ if self._kind == "tool":
906
+ otel_span.set_attribute("gen_ai.tool.name", h.tool_name or self._name)
907
+ if self._kind == "retrieval":
908
+ otel_span.set_attribute("retrieval.query", self._name)
909
+ if h.input is not None:
910
+ otel_span.set_attribute("input.value", _serialize_attr(h.input))
911
+ if h.output is not None:
912
+ otel_span.set_attribute("output.value", _serialize_attr(h.output))
913
+ if h.model:
914
+ otel_span.set_attribute("gen_ai.request.model", h.model)
915
+ if h.provider:
916
+ otel_span.set_attribute("gen_ai.system", h.provider)
917
+ if h.input_tokens is not None:
918
+ otel_span.set_attribute("gen_ai.usage.input_tokens", h.input_tokens)
919
+ if h.output_tokens is not None:
920
+ otel_span.set_attribute("gen_ai.usage.output_tokens", h.output_tokens)
921
+ if h.cost is not None:
922
+ otel_span.set_attribute("gen_ai.usage.cost", h.cost)
923
+ if h.temperature is not None:
924
+ otel_span.set_attribute("gen_ai.request.temperature", h.temperature)
925
+ if h.tool_name:
926
+ otel_span.set_attribute("gen_ai.tool.name", h.tool_name)
927
+ for k, v in (h.attributes or {}).items():
928
+ otel_span.set_attribute(f"trodo.span.{k}", _serialize_attr(v))
929
+ einfo = _resolve_error(h, exc_type, exc, tb)
930
+ if einfo["status"] == "error":
931
+ if exc is not None and hasattr(otel_span, "record_exception"):
932
+ otel_span.record_exception(exc)
933
+ _, status_cls, status_code = get_otel_helpers()
934
+ if status_cls is not None and status_code is not None:
935
+ otel_span.set_status(
936
+ status_cls(status_code.ERROR, einfo["error_message"] or "")
937
+ )
938
+ finally:
939
+ otel_span.end()
940
+ if self._otel_cm is not None:
941
+ try:
942
+ self._otel_cm.__exit__(exc_type, exc, tb)
943
+ except Exception:
944
+ pass
945
+ self._otel_span = None
946
+ self._otel_cm = None
947
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.10.3
3
+ Version: 2.10.5
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
File without changes
File without changes