trodo-python 2.3.1__tar.gz → 2.4.1__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 (44) hide show
  1. {trodo_python-2.3.1/trodo_python.egg-info → trodo_python-2.4.1}/PKG-INFO +38 -1
  2. trodo_python-2.3.1/PKG-INFO → trodo_python-2.4.1/README.md +33 -27
  3. {trodo_python-2.3.1 → trodo_python-2.4.1}/pyproject.toml +8 -1
  4. trodo_python-2.4.1/tests/test_auto_instrument_fixes.py +111 -0
  5. trodo_python-2.4.1/tests/test_register_otel.py +183 -0
  6. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/__init__.py +46 -1
  7. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/client.py +19 -1
  8. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/auto_instrument.py +54 -12
  9. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/helpers.py +9 -4
  10. trodo_python-2.4.1/trodo/otel/register.py +200 -0
  11. trodo_python-2.4.1/trodo/otel/transport.py +64 -0
  12. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/wrap_agent.py +109 -1
  13. trodo_python-2.3.1/README.md → trodo_python-2.4.1/trodo_python.egg-info/PKG-INFO +64 -0
  14. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo_python.egg-info/SOURCES.txt +4 -0
  15. trodo_python-2.4.1/trodo_python.egg-info/requires.txt +15 -0
  16. trodo_python-2.3.1/trodo_python.egg-info/requires.txt +0 -10
  17. {trodo_python-2.3.1 → trodo_python-2.4.1}/setup.cfg +0 -0
  18. {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_cross_process_session.py +0 -0
  19. {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_end_run.py +0 -0
  20. {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_processor_methods.py +0 -0
  21. {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_start_run.py +0 -0
  22. {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_wrap_agent_unchanged.py +0 -0
  23. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/api/__init__.py +0 -0
  24. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/api/async_client.py +0 -0
  25. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/api/endpoints.py +0 -0
  26. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/api/http_client.py +0 -0
  27. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/auto/__init__.py +0 -0
  28. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/auto/auto_event_manager.py +0 -0
  29. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/managers/__init__.py +0 -0
  30. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/managers/group_manager.py +0 -0
  31. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/managers/people_manager.py +0 -0
  32. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/__init__.py +0 -0
  33. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/context.py +0 -0
  34. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/processor.py +0 -0
  35. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/queue/__init__.py +0 -0
  36. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/queue/batch_flusher.py +0 -0
  37. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/queue/event_queue.py +0 -0
  38. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/session/__init__.py +0 -0
  39. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/session/server_session.py +0 -0
  40. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/session/session_manager.py +0 -0
  41. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/types.py +0 -0
  42. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/user_context.py +0 -0
  43. {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo_python.egg-info/dependency_links.txt +0 -0
  44. {trodo_python-2.3.1 → trodo_python-2.4.1}/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.3.1
3
+ Version: 2.4.1
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -19,6 +19,10 @@ Description-Content-Type: text/markdown
19
19
  Requires-Dist: requests>=2.28.0
20
20
  Provides-Extra: async
21
21
  Requires-Dist: httpx>=0.27.0; extra == "async"
22
+ Provides-Extra: otlp
23
+ Requires-Dist: opentelemetry-api>=1.20.0; extra == "otlp"
24
+ Requires-Dist: opentelemetry-sdk>=1.20.0; extra == "otlp"
25
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20.0; extra == "otlp"
22
26
  Provides-Extra: dev
23
27
  Requires-Dist: pytest>=7.0; extra == "dev"
24
28
  Requires-Dist: pytest-cov; extra == "dev"
@@ -37,6 +41,39 @@ pip install trodo-python
37
41
 
38
42
  Requires Python 3.8+.
39
43
 
44
+ ## OpenTelemetry / OTLP path (NEW in 2.4.0)
45
+
46
+ Already running OTel? Skip the Trodo SDK install entirely and point your existing pipeline at Trodo. Two env vars:
47
+
48
+ ```bash
49
+ export OTEL_EXPORTER_OTLP_ENDPOINT=https://sdkapi.trodo.ai
50
+ export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ${TRODO_SITE_ID}"
51
+ ```
52
+
53
+ The Bearer token is your **site_id** — same value you'd pass to `trodo.init(site_id=...)`. Get it from the [Integration Manager](https://app.trodo.ai/dashboard/integrations).
54
+
55
+ Use this when **you already run OTel** (Datadog, Jaeger, Honeycomb) and want Trodo as an additional destination. Install `trodo-python` and call `trodo.register_otel(site_id=..., mode='otlp')` to attach our OTLP exporter without replacing your existing setup. `wrap_agent` then routes through OTel so auto-instrumented children share the same trace.
56
+
57
+ ```python
58
+ # At app startup (after your existing OTel provider is registered)
59
+ import os, trodo
60
+
61
+ trodo.register_otel(
62
+ site_id=os.environ['TRODO_SITE_ID'],
63
+ mode='otlp',
64
+ )
65
+ ```
66
+
67
+ `mode='otlp'` requires the optional `[otlp]` extras:
68
+
69
+ ```bash
70
+ pip install 'trodo-python[otlp]'
71
+ ```
72
+
73
+ The SDK raises a friendly install hint if you call `mode='otlp'` without them.
74
+
75
+ For richer Trodo features on top (`wrap_agent`, `feedback`, `track_mcp`), continue with the SDK quick start below.
76
+
40
77
  ## Quick Start
41
78
 
42
79
  ```python
@@ -1,30 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: trodo-python
3
- Version: 2.3.1
4
- Summary: Trodo Analytics SDK for Python — server-side event tracking
5
- License: ISC
6
- Keywords: analytics,tracking,trodo,server-side
7
- Classifier: Programming Language :: Python :: 3
8
- Classifier: Programming Language :: Python :: 3.8
9
- Classifier: Programming Language :: Python :: 3.9
10
- Classifier: Programming Language :: Python :: 3.10
11
- Classifier: Programming Language :: Python :: 3.11
12
- Classifier: Programming Language :: Python :: 3.12
13
- Classifier: License :: OSI Approved :: ISC License (ISCL)
14
- Classifier: Operating System :: OS Independent
15
- Classifier: Intended Audience :: Developers
16
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
- Requires-Python: >=3.8
18
- Description-Content-Type: text/markdown
19
- Requires-Dist: requests>=2.28.0
20
- Provides-Extra: async
21
- Requires-Dist: httpx>=0.27.0; extra == "async"
22
- Provides-Extra: dev
23
- Requires-Dist: pytest>=7.0; extra == "dev"
24
- Requires-Dist: pytest-cov; extra == "dev"
25
- Requires-Dist: responses>=0.25.0; extra == "dev"
26
- Requires-Dist: httpx>=0.27.0; extra == "dev"
27
-
28
1
  # trodo-python
29
2
 
30
3
  Server-side Python SDK for [Trodo Analytics](https://trodo.ai). Track backend events, identify users, manage people/groups, and instrument AI agents — all unified with your frontend data under the same `site_id`.
@@ -37,6 +10,39 @@ pip install trodo-python
37
10
 
38
11
  Requires Python 3.8+.
39
12
 
13
+ ## OpenTelemetry / OTLP path (NEW in 2.4.0)
14
+
15
+ Already running OTel? Skip the Trodo SDK install entirely and point your existing pipeline at Trodo. Two env vars:
16
+
17
+ ```bash
18
+ export OTEL_EXPORTER_OTLP_ENDPOINT=https://sdkapi.trodo.ai
19
+ export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ${TRODO_SITE_ID}"
20
+ ```
21
+
22
+ The Bearer token is your **site_id** — same value you'd pass to `trodo.init(site_id=...)`. Get it from the [Integration Manager](https://app.trodo.ai/dashboard/integrations).
23
+
24
+ Use this when **you already run OTel** (Datadog, Jaeger, Honeycomb) and want Trodo as an additional destination. Install `trodo-python` and call `trodo.register_otel(site_id=..., mode='otlp')` to attach our OTLP exporter without replacing your existing setup. `wrap_agent` then routes through OTel so auto-instrumented children share the same trace.
25
+
26
+ ```python
27
+ # At app startup (after your existing OTel provider is registered)
28
+ import os, trodo
29
+
30
+ trodo.register_otel(
31
+ site_id=os.environ['TRODO_SITE_ID'],
32
+ mode='otlp',
33
+ )
34
+ ```
35
+
36
+ `mode='otlp'` requires the optional `[otlp]` extras:
37
+
38
+ ```bash
39
+ pip install 'trodo-python[otlp]'
40
+ ```
41
+
42
+ The SDK raises a friendly install hint if you call `mode='otlp'` without them.
43
+
44
+ For richer Trodo features on top (`wrap_agent`, `feedback`, `track_mcp`), continue with the SDK quick start below.
45
+
40
46
  ## Quick Start
41
47
 
42
48
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.3.1"
7
+ version = "2.4.1"
8
8
  description = "Trodo Analytics SDK for Python — server-side event tracking"
9
9
  readme = "README.md"
10
10
  license = { text = "ISC" }
@@ -28,6 +28,13 @@ classifiers = [
28
28
 
29
29
  [project.optional-dependencies]
30
30
  async = ["httpx>=0.27.0"]
31
+ # OTLP-mode dependencies for register_otel(mode='otlp'). Install with:
32
+ # pip install 'trodo-python[otlp]'
33
+ otlp = [
34
+ "opentelemetry-api>=1.20.0",
35
+ "opentelemetry-sdk>=1.20.0",
36
+ "opentelemetry-exporter-otlp-proto-http>=1.20.0",
37
+ ]
31
38
  dev = [
32
39
  "pytest>=7.0",
33
40
  "pytest-cov",
@@ -0,0 +1,111 @@
1
+ """Mirror of trodo-node 2.4.3 bridge fixes for the Python SDK.
2
+
3
+ Covers:
4
+ 1. duration_ms is rounded to an integer.
5
+ 2. Active run is recovered from on_start-stamped attributes when
6
+ contextvars are empty at on_end (httpx async-context loss).
7
+ 3. Internal trodo.* attributes are stripped from the persisted span.
8
+ """
9
+
10
+ from types import SimpleNamespace
11
+ from unittest.mock import MagicMock
12
+
13
+ import pytest
14
+
15
+ from trodo.otel.auto_instrument import _OtelAdapter, otel_span_to_trodo_span
16
+ from trodo.otel.context import (
17
+ ActiveSpanContext,
18
+ get_active_context,
19
+ run_with_context,
20
+ )
21
+
22
+
23
+ RUN_ID = "11111111-1111-1111-1111-111111111111"
24
+ ROOT_SPAN_ID = "22222222-2222-2222-2222-222222222222"
25
+
26
+
27
+ def _fake_otel_span(start_ns=None, end_ns=None, attrs=None, span_id_int=0xABCD1234ABCD1234, parent_span_id_int=None):
28
+ span_context = SimpleNamespace(span_id=span_id_int)
29
+ parent = SimpleNamespace(span_id=parent_span_id_int) if parent_span_id_int else None
30
+ return SimpleNamespace(
31
+ name="anthropic.messages.create",
32
+ get_span_context=lambda: span_context,
33
+ attributes=attrs or {},
34
+ start_time=start_ns,
35
+ end_time=end_ns,
36
+ status=None,
37
+ parent=parent,
38
+ )
39
+
40
+
41
+ def _ctx():
42
+ return ActiveSpanContext(
43
+ run_id=RUN_ID,
44
+ span_id=ROOT_SPAN_ID,
45
+ parent_span_id=None,
46
+ team_site_id="site-1",
47
+ processor=None, # type: ignore[arg-type]
48
+ )
49
+
50
+
51
+ class TestDurationRounding:
52
+ def test_fractional_ns_delta_rounds_to_integer(self):
53
+ # 1000ms + 0.999888 fractional ms via nanos delta
54
+ otel = _fake_otel_span(start_ns=1_000_000_000, end_ns=2_000_999_888)
55
+ with run_with_context(_ctx()):
56
+ span = otel_span_to_trodo_span(otel)
57
+ assert span is not None
58
+ assert isinstance(span.duration_ms, int)
59
+ assert span.duration_ms == 1001
60
+
61
+ def test_missing_times_leaves_duration_none(self):
62
+ otel = _fake_otel_span(start_ns=None, end_ns=None)
63
+ with run_with_context(_ctx()):
64
+ span = otel_span_to_trodo_span(otel)
65
+ assert span.duration_ms is None
66
+
67
+
68
+ class TestOnStartStamping:
69
+ def test_on_start_stamps_run_id_when_context_is_alive(self):
70
+ adapter = _OtelAdapter(processor=MagicMock())
71
+ live_span = MagicMock()
72
+ with run_with_context(_ctx()):
73
+ adapter.on_start(live_span, None)
74
+ live_span.set_attribute.assert_any_call("trodo.run_id", RUN_ID)
75
+ live_span.set_attribute.assert_any_call("trodo.parent_span_id", ROOT_SPAN_ID)
76
+
77
+ def test_recovers_run_from_stamped_attrs_when_contextvars_empty(self):
78
+ # Simulate the failure mode: span fires on_end outside any wrap_agent.
79
+ assert get_active_context() is None
80
+ otel = _fake_otel_span(
81
+ start_ns=1_000_000_000,
82
+ end_ns=2_000_000_000,
83
+ attrs={
84
+ "trodo.run_id": RUN_ID,
85
+ "trodo.parent_span_id": ROOT_SPAN_ID,
86
+ "gen_ai.request.model": "claude-3-7",
87
+ },
88
+ )
89
+ span = otel_span_to_trodo_span(otel)
90
+ assert span is not None
91
+ assert span.run_id == RUN_ID
92
+ assert span.parent_span_id == ROOT_SPAN_ID
93
+
94
+ def test_strips_trodo_attrs_from_persisted_span(self):
95
+ otel = _fake_otel_span(
96
+ start_ns=1_000_000_000,
97
+ end_ns=2_000_000_000,
98
+ attrs={
99
+ "trodo.run_id": RUN_ID,
100
+ "trodo.parent_span_id": ROOT_SPAN_ID,
101
+ "gen_ai.request.model": "gpt-4",
102
+ },
103
+ )
104
+ span = otel_span_to_trodo_span(otel)
105
+ assert "trodo.run_id" not in (span.attributes or {})
106
+ assert "trodo.parent_span_id" not in (span.attributes or {})
107
+ assert (span.attributes or {}).get("gen_ai.request.model") == "gpt-4"
108
+
109
+ def test_drops_span_when_no_run_id_anywhere(self):
110
+ otel = _fake_otel_span(start_ns=1, end_ns=2, attrs={})
111
+ assert otel_span_to_trodo_span(otel) is None
@@ -0,0 +1,183 @@
1
+ """Tests for register_otel + transport dispatch (Python parity with the TS SDK)."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from trodo.otel.register import register_otel, _reset_for_tests
7
+ from trodo.otel.transport import (
8
+ get_transport_mode,
9
+ get_otel_tracer,
10
+ set_otel_transport,
11
+ _reset_transport_for_tests,
12
+ )
13
+ from trodo.otel.processor import TrodoSpanProcessor
14
+ from trodo.otel.wrap_agent import wrap_agent
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def reset_state():
19
+ _reset_for_tests()
20
+ yield
21
+ _reset_for_tests()
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # register_otel — argument validation + idempotency
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def test_raises_when_site_id_missing(processor):
30
+ with pytest.raises(ValueError, match="site_id"):
31
+ register_otel(site_id="", processor=processor)
32
+
33
+
34
+ def test_raises_when_processor_missing():
35
+ with pytest.raises(ValueError, match="processor"):
36
+ register_otel(site_id="site_x", processor=None) # type: ignore[arg-type]
37
+
38
+
39
+ def test_idempotency_warns_on_second_call(processor):
40
+ first = register_otel(site_id="site_x", processor=processor, mode="trodo")
41
+ assert first.mode == "trodo"
42
+ assert first.fresh is True
43
+
44
+ with pytest.warns(UserWarning, match="already called"):
45
+ second = register_otel(site_id="site_x", processor=processor, mode="trodo")
46
+ assert second is first
47
+
48
+
49
+ def test_unknown_mode_raises(processor):
50
+ with pytest.raises(ValueError, match="unknown mode"):
51
+ register_otel(site_id="site_x", processor=processor, mode="bogus")
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Mode dispatch — 'trodo' default, 'otlp' raises if peer deps missing
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def test_trodo_mode_succeeds_without_otlp_peer_deps(processor):
60
+ result = register_otel(site_id="site_x", processor=processor, mode="trodo")
61
+ assert result.mode == "trodo"
62
+ # active_instrumentations is best-effort; just confirm it's a list.
63
+ assert isinstance(result.active_instrumentations, list)
64
+
65
+
66
+ def test_otlp_mode_emits_install_hint_when_peer_deps_missing(processor, monkeypatch):
67
+ # Ensure opentelemetry imports fail to simulate a fresh install without
68
+ # the 'otlp' extras. We do this by replacing opentelemetry.sdk.trace's
69
+ # spec in sys.modules with None, mimicking ImportError at import time.
70
+ import sys
71
+
72
+ sentinel = "opentelemetry.exporter.otlp.proto.http.trace_exporter"
73
+ monkeypatch.setitem(sys.modules, sentinel, None)
74
+
75
+ with pytest.raises(RuntimeError, match=r"register_otel\(mode='otlp'\) requires"):
76
+ register_otel(site_id="site_x", processor=processor, mode="otlp")
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Transport dispatch — wrap_agent in 'otlp' mode goes through the OTel tracer
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ class RecordedSpan:
85
+ def __init__(self, name: str, trace_id: int, span_id: int):
86
+ self.name = name
87
+ self.trace_id = trace_id
88
+ self.span_id = span_id
89
+ self.attrs = {}
90
+ self.ended = False
91
+ self.exception = None
92
+ self.status = None
93
+
94
+ def set_attribute(self, key, value):
95
+ self.attrs[key] = value
96
+
97
+ def record_exception(self, e):
98
+ self.exception = e
99
+
100
+ def set_status(self, s):
101
+ self.status = s
102
+
103
+ def end(self):
104
+ self.ended = True
105
+
106
+ def get_span_context(self):
107
+ class C:
108
+ pass
109
+ c = C()
110
+ c.trace_id = self.trace_id
111
+ c.span_id = self.span_id
112
+ return c
113
+
114
+
115
+ class MockTracer:
116
+ def __init__(self):
117
+ self.spans: list = []
118
+ self._next_id = 1
119
+
120
+ def start_span(self, name):
121
+ span = RecordedSpan(name, self._next_id, self._next_id + 1000)
122
+ self._next_id += 1
123
+ self.spans.append(span)
124
+ return span
125
+
126
+
127
+ def test_wrap_agent_dispatches_to_otel_when_transport_set(processor):
128
+ """wrap_agent in 'otlp' transport routes through tracer.start_span and
129
+ sets Trodo attributes the backend OTLP controller understands."""
130
+ tracer = MockTracer()
131
+
132
+ class _NoopCM:
133
+ def __enter__(self):
134
+ return None
135
+
136
+ def __exit__(self, *a):
137
+ return None
138
+
139
+ def fake_use_span(span, end_on_exit=False):
140
+ return _NoopCM()
141
+
142
+ set_otel_transport(tracer, use_span=fake_use_span)
143
+
144
+ with wrap_agent(
145
+ processor=processor,
146
+ team_site_id="site-x",
147
+ agent_name="support-bot",
148
+ distinct_id="user-1",
149
+ conversation_id="conv-1",
150
+ metadata={"experimentId": "v3", "tier": "enterprise"},
151
+ ) as run:
152
+ run.set_input({"q": "hello"})
153
+ run.set_output({"a": "world"})
154
+
155
+ assert len(tracer.spans) == 1
156
+ span = tracer.spans[0]
157
+ assert span.name == "support-bot"
158
+ assert span.ended is True
159
+ assert span.attrs["trodo.agent_name"] == "support-bot"
160
+ assert span.attrs["trodo.distinct_id"] == "user-1"
161
+ assert span.attrs["trodo.conversation_id"] == "conv-1"
162
+ assert span.attrs["trodo.metadata.experimentId"] == "v3"
163
+ assert span.attrs["trodo.metadata.tier"] == "enterprise"
164
+ assert "ai.prompt" in span.attrs
165
+ assert "ai.response.text" in span.attrs
166
+
167
+
168
+ def test_wrap_agent_legacy_path_when_no_transport(processor, http):
169
+ """No register_otel call → transport mode stays 'trodo' → wrap_agent
170
+ uses TrodoSpanProcessor + HTTP API as in 2.3.x."""
171
+ assert get_transport_mode() == "trodo"
172
+ assert get_otel_tracer() is None
173
+
174
+ with wrap_agent(
175
+ processor=processor,
176
+ team_site_id="site-x",
177
+ agent_name="legacy-agent",
178
+ ) as run:
179
+ run.set_output("done")
180
+
181
+ # Trodo HTTP API path used as in 2.3.x.
182
+ assert len(http.run_ingest) == 1
183
+ assert http.run_ingest[0]["run"]["agent_name"] == "legacy-agent"
@@ -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.3.1"
43
+ __version__ = "2.4.0"
44
44
 
45
45
  from typing import Any, Callable, Dict, List, Optional, Union
46
46
 
@@ -67,6 +67,7 @@ __all__ = [
67
67
  "SpanHandle",
68
68
  "FeedbackProps",
69
69
  "init",
70
+ "register_otel",
70
71
  "for_user",
71
72
  "track",
72
73
  "identify",
@@ -129,6 +130,7 @@ def init(
129
130
  on_error: Optional[Any] = None,
130
131
  debug: bool = False,
131
132
  auto_instrument: bool = True,
133
+ otel_mode: str = "trodo",
132
134
  ) -> TrodoClient:
133
135
  """Initialise the singleton SDK instance.
134
136
 
@@ -150,10 +152,53 @@ def init(
150
152
  on_error=on_error,
151
153
  debug=debug,
152
154
  auto_instrument=auto_instrument,
155
+ otel_mode=otel_mode,
153
156
  )
154
157
  return _client
155
158
 
156
159
 
160
+ def register_otel(
161
+ *,
162
+ site_id: Optional[str] = None,
163
+ mode: str = "trodo",
164
+ endpoint: Optional[str] = None,
165
+ service_name: str = "trodo-agent",
166
+ ) -> Any:
167
+ """Configure the OTel pipeline with one call.
168
+
169
+ Auto-creates the singleton TrodoClient if init() hasn't been called yet,
170
+ so this can replace init() for OTel-first deployments.
171
+
172
+ Modes:
173
+ - 'trodo' (default): bridges OTel auto-instrumentations into
174
+ TrodoSpanProcessor (HTTP API path). Same as init() with default config.
175
+ - 'otlp': registers an OTel TracerProvider with an OTLP/protobuf
176
+ exporter pointed at Trodo's /v1/traces. Use alongside Datadog/Jaeger
177
+ or for vanilla OTel-native setups.
178
+
179
+ Example:
180
+ import trodo
181
+ trodo.register_otel(site_id=os.environ['TRODO_SITE_ID'], mode='otlp')
182
+ """
183
+ global _client
184
+ from .otel.register import register_otel as _impl
185
+ if _client is None:
186
+ if not site_id:
187
+ raise ValueError(
188
+ "register_otel: pass site_id, or call trodo.init(site_id=...) first."
189
+ )
190
+ # Suppress legacy auto_instrument inside the constructor — register_otel
191
+ # below is what wires the pipeline.
192
+ _client = TrodoClient(site_id=site_id, auto_instrument=False)
193
+ return _impl(
194
+ site_id=site_id or _client.site_id,
195
+ processor=_client._get_span_processor(),
196
+ mode=mode,
197
+ endpoint=endpoint,
198
+ service_name=service_name,
199
+ )
200
+
201
+
157
202
  def for_user(
158
203
  distinct_id: str,
159
204
  session_id: Optional[str] = None,
@@ -23,6 +23,7 @@ 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
27
  from .otel.helpers import (
27
28
  tool as tool_decorator,
28
29
  track_llm_call as track_llm_call_fn,
@@ -52,11 +53,13 @@ class TrodoClient:
52
53
  on_error: Optional[Any] = None,
53
54
  debug: bool = False,
54
55
  auto_instrument: bool = True,
56
+ otel_mode: str = "trodo",
55
57
  ) -> None:
56
58
  if not site_id:
57
59
  raise ValueError("trodo-python: site_id is required")
58
60
 
59
61
  self.site_id = site_id
62
+ self._api_base = api_base
60
63
 
61
64
  self._http = HttpClient(
62
65
  api_base=api_base,
@@ -90,7 +93,22 @@ class TrodoClient:
90
93
 
91
94
  self._span_processor = TrodoSpanProcessor(http_client=self._http)
92
95
  if auto_instrument:
93
- enable_auto_instrument(self._span_processor)
96
+ import os
97
+ if os.environ.get("TRODO_LEGACY_INIT") == "1":
98
+ # Pre-2.4 escape hatch for one minor version.
99
+ enable_auto_instrument(self._span_processor)
100
+ else:
101
+ _register_otel(
102
+ site_id=site_id,
103
+ processor=self._span_processor,
104
+ mode=otel_mode,
105
+ endpoint=api_base,
106
+ )
107
+
108
+ def _get_span_processor(self) -> TrodoSpanProcessor:
109
+ """Internal: expose the span processor to register_otel. Not part of
110
+ the public surface; subject to change between minors."""
111
+ return self._span_processor
94
112
 
95
113
  # --------------------------------------------------------------------------
96
114
  # Primary pattern: for_user()
@@ -34,11 +34,17 @@ def _infer_kind(attrs: Dict[str, Any]) -> str:
34
34
  return "generic"
35
35
 
36
36
 
37
+ _ATTR_TRODO_RUN_ID = "trodo.run_id"
38
+ _ATTR_TRODO_PARENT_SPAN_ID = "trodo.parent_span_id"
39
+
40
+
37
41
  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
+ """Translate an OTel ReadableSpan to our TrodoSpan using GenAI semconv.
43
+
44
+ Recovers the active run from on_start-stamped attributes when contextvars
45
+ have been clobbered by httpx/asyncio context loss at span end. Mirrors
46
+ the trodo-node 2.4.3 bridge behaviour.
47
+ """
42
48
  try:
43
49
  span_ctx = (
44
50
  otel_span.get_span_context()
@@ -53,15 +59,36 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
53
59
  return None
54
60
  span_id = f"{span_id_int:016x}" if isinstance(span_id_int, int) else str(span_id_int)
55
61
 
62
+ attrs = dict(getattr(otel_span, "attributes", {}) or {})
63
+
64
+ # Prefer on_start-stamped run/parent so async-context loss can't drop
65
+ # auto-instrumented LLM spans.
66
+ stamped_run_id = attrs.get(_ATTR_TRODO_RUN_ID)
67
+ stamped_parent_span_id = attrs.get(_ATTR_TRODO_PARENT_SPAN_ID)
68
+
69
+ ctx = get_active_context()
70
+ run_id = stamped_run_id if isinstance(stamped_run_id, str) else (ctx.run_id if ctx else None)
71
+ if not run_id:
72
+ return None # emitted outside any wrap_agent — drop
73
+
56
74
  parent = getattr(otel_span, "parent", None)
57
- parent_span_id: Optional[str] = None
75
+ otel_parent_span_id: Optional[str] = None
58
76
  if parent is not None:
59
77
  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
78
+ otel_parent_span_id = (
79
+ f"{pid:016x}" if isinstance(pid, int) else str(pid) if pid else None
80
+ )
63
81
 
64
- attrs = dict(getattr(otel_span, "attributes", {}) or {})
82
+ if isinstance(stamped_parent_span_id, str):
83
+ parent_span_id: Optional[str] = stamped_parent_span_id
84
+ elif otel_parent_span_id:
85
+ parent_span_id = otel_parent_span_id
86
+ else:
87
+ parent_span_id = ctx.span_id if ctx else None
88
+
89
+ # Strip the internal attrs so they don't leak into the persisted span.
90
+ attrs.pop(_ATTR_TRODO_RUN_ID, None)
91
+ attrs.pop(_ATTR_TRODO_PARENT_SPAN_ID, None)
65
92
  kind = _infer_kind(attrs)
66
93
 
67
94
  start_time = getattr(otel_span, "start_time", None)
@@ -70,7 +97,9 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
70
97
  ended_at = _hr_to_iso(end_time)
71
98
  duration_ms = None
72
99
  if start_time and end_time:
73
- duration_ms = max(0, int((end_time - start_time) / 1e6))
100
+ # round-half-to-even semantics are fine here; the constraint is
101
+ # "integer ms" matching the agent_spans.duration_ms column.
102
+ duration_ms = max(0, round((end_time - start_time) / 1e6))
74
103
 
75
104
  status = getattr(otel_span, "status", None)
76
105
  status_code = getattr(status, "status_code", None)
@@ -104,7 +133,7 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
104
133
 
105
134
  return TrodoSpan(
106
135
  span_id=span_id,
107
- run_id=ctx.run_id,
136
+ run_id=run_id,
108
137
  parent_span_id=parent_span_id,
109
138
  kind=kind,
110
139
  name=getattr(otel_span, "name", kind),
@@ -131,7 +160,20 @@ class _OtelAdapter:
131
160
  self._processor = processor
132
161
 
133
162
  def on_start(self, span: Any, parent_context: Any = None) -> None:
134
- pass
163
+ # Stamp active run/parent ids while contextvars are still alive.
164
+ # See otel_span_to_trodo_span() docstring for the failure mode this
165
+ # guards against (async-context loss across httpx await boundaries).
166
+ ctx = get_active_context()
167
+ if ctx is None:
168
+ return
169
+ try:
170
+ set_attr = getattr(span, "set_attribute", None)
171
+ if callable(set_attr):
172
+ set_attr(_ATTR_TRODO_RUN_ID, ctx.run_id)
173
+ if ctx.span_id:
174
+ set_attr(_ATTR_TRODO_PARENT_SPAN_ID, ctx.span_id)
175
+ except Exception:
176
+ pass # never break user code
135
177
 
136
178
  def on_end(self, span: Any) -> None:
137
179
  trodo_span = otel_span_to_trodo_span(span)