trodo-python 2.3.0__tar.gz → 2.4.0__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 (43) hide show
  1. {trodo_python-2.3.0/trodo_python.egg-info → trodo_python-2.4.0}/PKG-INFO +38 -1
  2. trodo_python-2.3.0/PKG-INFO → trodo_python-2.4.0/README.md +33 -27
  3. {trodo_python-2.3.0 → trodo_python-2.4.0}/pyproject.toml +8 -1
  4. trodo_python-2.4.0/tests/test_register_otel.py +183 -0
  5. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/__init__.py +46 -1
  6. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/client.py +19 -1
  7. trodo_python-2.4.0/trodo/otel/register.py +200 -0
  8. trodo_python-2.4.0/trodo/otel/transport.py +64 -0
  9. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/otel/wrap_agent.py +109 -1
  10. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/session/server_session.py +1 -0
  11. trodo_python-2.3.0/README.md → trodo_python-2.4.0/trodo_python.egg-info/PKG-INFO +64 -0
  12. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo_python.egg-info/SOURCES.txt +3 -0
  13. trodo_python-2.4.0/trodo_python.egg-info/requires.txt +15 -0
  14. trodo_python-2.3.0/trodo_python.egg-info/requires.txt +0 -10
  15. {trodo_python-2.3.0 → trodo_python-2.4.0}/setup.cfg +0 -0
  16. {trodo_python-2.3.0 → trodo_python-2.4.0}/tests/test_cross_process_session.py +0 -0
  17. {trodo_python-2.3.0 → trodo_python-2.4.0}/tests/test_end_run.py +0 -0
  18. {trodo_python-2.3.0 → trodo_python-2.4.0}/tests/test_processor_methods.py +0 -0
  19. {trodo_python-2.3.0 → trodo_python-2.4.0}/tests/test_start_run.py +0 -0
  20. {trodo_python-2.3.0 → trodo_python-2.4.0}/tests/test_wrap_agent_unchanged.py +0 -0
  21. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/api/__init__.py +0 -0
  22. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/api/async_client.py +0 -0
  23. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/api/endpoints.py +0 -0
  24. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/api/http_client.py +0 -0
  25. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/auto/__init__.py +0 -0
  26. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/auto/auto_event_manager.py +0 -0
  27. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/managers/__init__.py +0 -0
  28. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/managers/group_manager.py +0 -0
  29. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/managers/people_manager.py +0 -0
  30. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/otel/__init__.py +0 -0
  31. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/otel/auto_instrument.py +0 -0
  32. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/otel/context.py +0 -0
  33. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/otel/helpers.py +0 -0
  34. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/otel/processor.py +0 -0
  35. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/queue/__init__.py +0 -0
  36. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/queue/batch_flusher.py +0 -0
  37. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/queue/event_queue.py +0 -0
  38. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/session/__init__.py +0 -0
  39. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/session/session_manager.py +0 -0
  40. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/types.py +0 -0
  41. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo/user_context.py +0 -0
  42. {trodo_python-2.3.0 → trodo_python-2.4.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  43. {trodo_python-2.3.0 → trodo_python-2.4.0}/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.0
3
+ Version: 2.4.0
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.0
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.0"
7
+ version = "2.4.0"
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,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.0"
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()
@@ -0,0 +1,200 @@
1
+ """
2
+ register_otel — one-call setup for Trodo's tracing pipeline.
3
+
4
+ Mirrors trodo-node's registerOTel. Two modes:
5
+
6
+ - 'trodo' (default) — uses the existing enable_auto_instrument bridge so spans
7
+ from opentelemetry-instrumentation-{anthropic,openai,langchain,...} flow
8
+ into TrodoSpanProcessor (HTTP API path). Functionally equivalent to today's
9
+ init() with auto_instrument=True, exposed as a one-line OTel-native call.
10
+
11
+ - 'otlp' — registers an OTel TracerProvider with an OTLP/protobuf exporter
12
+ pointed at Trodo's /v1/traces ingest. Use when you already have an OTel
13
+ pipeline (Datadog, Jaeger, Honeycomb) or want a vanilla OTel-native setup.
14
+ In 'otlp' mode, wrap_agent / span dispatch through the OTel tracer so
15
+ auto-instrumented children (Anthropic, LangChain, etc.) join the same
16
+ trace; backend OTLP controller groups them into one run.
17
+
18
+ Idempotent — second call returns the cached configuration with a warning.
19
+ """
20
+ from __future__ import annotations
21
+ import warnings
22
+ from dataclasses import dataclass
23
+ from typing import Any, List, Optional
24
+
25
+ from .processor import TrodoSpanProcessor
26
+ from .auto_instrument import enable_auto_instrument
27
+ from .transport import set_otel_transport, _reset_transport_for_tests
28
+
29
+
30
+ @dataclass
31
+ class RegisterOtelResult:
32
+ mode: str
33
+ active_instrumentations: List[str]
34
+ fresh: bool
35
+
36
+
37
+ _registered: Optional[RegisterOtelResult] = None
38
+
39
+
40
+ def register_otel(
41
+ *,
42
+ site_id: str,
43
+ processor: TrodoSpanProcessor,
44
+ mode: str = "trodo",
45
+ endpoint: Optional[str] = None,
46
+ service_name: str = "trodo-agent",
47
+ disable_instrumentations: Optional[List[str]] = None,
48
+ ) -> RegisterOtelResult:
49
+ """Configure Trodo's OTel pipeline. See module doc for mode semantics."""
50
+ global _registered
51
+ if _registered is not None:
52
+ warnings.warn(
53
+ "[trodo] register_otel already called; returning existing "
54
+ "configuration. Pass {mode} only at startup.",
55
+ stacklevel=2,
56
+ )
57
+ return _registered
58
+ if not site_id:
59
+ raise ValueError("register_otel: site_id is required")
60
+ if processor is None:
61
+ raise ValueError(
62
+ "register_otel: processor is required (typically passed "
63
+ "automatically via init() / TrodoClient).",
64
+ )
65
+
66
+ endpoint = (endpoint or "https://sdkapi.trodo.ai").rstrip("/")
67
+
68
+ if mode == "trodo":
69
+ active = enable_auto_instrument(processor)
70
+ elif mode == "otlp":
71
+ active = _setup_otlp_mode(
72
+ site_id=site_id,
73
+ endpoint=endpoint,
74
+ service_name=service_name,
75
+ disable=disable_instrumentations or [],
76
+ processor=processor,
77
+ )
78
+ else:
79
+ raise ValueError(f"register_otel: unknown mode {mode!r}")
80
+
81
+ _registered = RegisterOtelResult(
82
+ mode=mode, active_instrumentations=active, fresh=True
83
+ )
84
+ return _registered
85
+
86
+
87
+ def _reset_for_tests() -> None:
88
+ """Reset internal state — for tests only. Not exported from __init__.py."""
89
+ global _registered
90
+ _registered = None
91
+ _reset_transport_for_tests()
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # 'otlp' mode setup
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def _setup_otlp_mode(
100
+ *,
101
+ site_id: str,
102
+ endpoint: str,
103
+ service_name: str,
104
+ disable: List[str],
105
+ processor: TrodoSpanProcessor,
106
+ ) -> List[str]:
107
+ """Lazy-import OTel SDK + protobuf exporter; emit friendly install hint
108
+ if not installed (only users who pick 'otlp' mode need them)."""
109
+ try:
110
+ from opentelemetry import trace as otel_trace
111
+ from opentelemetry.trace import Status, StatusCode, use_span
112
+ from opentelemetry.sdk.trace import TracerProvider
113
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
114
+ from opentelemetry.sdk.resources import Resource
115
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
116
+ OTLPSpanExporter,
117
+ )
118
+ except ImportError as e:
119
+ raise RuntimeError(
120
+ "register_otel(mode='otlp') requires the OTel SDK + protobuf "
121
+ "exporter. Install with:\n\n"
122
+ " pip install 'trodo-python[otlp]'\n\n"
123
+ "or directly:\n\n"
124
+ " pip install opentelemetry-sdk "
125
+ "opentelemetry-exporter-otlp-proto-http\n\n"
126
+ f"Original error: {e}",
127
+ ) from e
128
+
129
+ exporter = OTLPSpanExporter(
130
+ endpoint=f"{endpoint}/v1/traces",
131
+ headers={"Authorization": f"Bearer {site_id}"},
132
+ )
133
+ batch_processor = BatchSpanProcessor(exporter)
134
+
135
+ # Coexistence: if a non-default TracerProvider is already registered,
136
+ # attach our exporter to it instead of replacing it.
137
+ existing = otel_trace.get_tracer_provider()
138
+ existing_class = type(existing).__name__
139
+ if existing_class not in ("ProxyTracerProvider", "NoOpTracerProvider"):
140
+ # User's pipeline owns the provider — just attach our processor.
141
+ if hasattr(existing, "add_span_processor"):
142
+ existing.add_span_processor(batch_processor)
143
+ # Auto-instrumentations are presumably already wired by the existing
144
+ # pipeline. We still set the transport so wrap_agent dispatches.
145
+ set_otel_transport(
146
+ otel_trace.get_tracer("trodo-agent"),
147
+ use_span=use_span,
148
+ status_cls=Status,
149
+ status_code=StatusCode,
150
+ )
151
+ return _build_instrumentations(disable)
152
+
153
+ # Fresh pipeline.
154
+ resource = Resource.create({
155
+ "service.name": service_name,
156
+ "trodo.sdk.mode": "otlp",
157
+ })
158
+ provider = TracerProvider(resource=resource)
159
+ provider.add_span_processor(batch_processor)
160
+ otel_trace.set_tracer_provider(provider)
161
+
162
+ active = _build_instrumentations(disable)
163
+
164
+ # Activate the OTel transport so wrap_agent / span dispatch via tracer.
165
+ set_otel_transport(otel_trace.get_tracer("trodo-agent"))
166
+ return active
167
+
168
+
169
+ def _build_instrumentations(disable: List[str]) -> List[str]:
170
+ """Best-effort install of OTel auto-instrumentations.
171
+
172
+ Mirrors auto_instrument.py's loader list but instruments via the OTel
173
+ tracer (which is now configured to ship to Trodo via OTLP), not via
174
+ TrodoSpanProcessor's adapter.
175
+ """
176
+ disabled = set(disable)
177
+ active: List[str] = []
178
+ candidates = [
179
+ ("anthropic", "opentelemetry.instrumentation.anthropic", "AnthropicInstrumentor"),
180
+ ("openai", "opentelemetry.instrumentation.openai", "OpenAIInstrumentor"),
181
+ ("langchain", "opentelemetry.instrumentation.langchain", "LangChainInstrumentor"),
182
+ ("llama-index", "opentelemetry.instrumentation.llamaindex", "LlamaIndexInstrumentor"),
183
+ ("bedrock", "opentelemetry.instrumentation.bedrock", "BedrockInstrumentor"),
184
+ ("cohere", "opentelemetry.instrumentation.cohere", "CohereInstrumentor"),
185
+ ("google-generativeai", "opentelemetry.instrumentation.google_generativeai", "GoogleGenerativeAiInstrumentor"),
186
+ ("vertexai", "opentelemetry.instrumentation.vertexai", "VertexAIInstrumentor"),
187
+ ("requests", "opentelemetry.instrumentation.requests", "RequestsInstrumentor"),
188
+ ("httpx", "opentelemetry.instrumentation.httpx", "HTTPXClientInstrumentor"),
189
+ ]
190
+ for short_id, mod_path, cls_name in candidates:
191
+ if short_id in disabled:
192
+ continue
193
+ try:
194
+ mod = __import__(mod_path, fromlist=[cls_name])
195
+ getattr(mod, cls_name)().instrument()
196
+ active.append(short_id)
197
+ except Exception:
198
+ # Package not installed or instrumentation already attached.
199
+ pass
200
+ return active
@@ -0,0 +1,64 @@
1
+ """
2
+ Transport mode + OTel tracer registry for wrap_agent / span dispatch.
3
+
4
+ Set by register_otel() at startup. Read by wrap_agent / span to decide whether
5
+ to use the legacy TrodoSpanProcessor + Trodo HTTP API path, or to route
6
+ through an OTel tracer (so auto-instrumented children join the same OTel
7
+ trace and the backend OTLP controller groups them into one run).
8
+
9
+ Default state: 'trodo' mode + None tracer = legacy behavior, identical to
10
+ the pre-2.4.0 SDK. Existing 2.3.x users see no change.
11
+ """
12
+ from __future__ import annotations
13
+ from typing import Any, Optional
14
+
15
+ # Transport state — module-level singletons.
16
+ _mode: str = "trodo" # 'trodo' | 'otlp'
17
+ _tracer: Optional[Any] = None
18
+ # Optional helpers loaded once at register_otel time. Stored here so
19
+ # wrap_agent / span don't have to do `from opentelemetry import ...` inside
20
+ # the hot path (which would force opentelemetry to be installed even in tests
21
+ # that mock the tracer).
22
+ _use_span: Optional[Any] = None # callable: (span, end_on_exit=False) -> ContextManager
23
+ _status_cls: Optional[Any] = None # opentelemetry.trace.Status (or shim)
24
+ _status_code: Optional[Any] = None # opentelemetry.trace.StatusCode (or shim)
25
+
26
+
27
+ def get_transport_mode() -> str:
28
+ """Read by wrap_agent / span. Returns 'trodo' if register_otel never called."""
29
+ return _mode
30
+
31
+
32
+ def get_otel_tracer() -> Optional[Any]:
33
+ """Read by wrap_agent / span in 'otlp' mode. None when transport is 'trodo'."""
34
+ return _tracer
35
+
36
+
37
+ def get_otel_helpers() -> tuple:
38
+ """Returns (use_span, Status, StatusCode) — all callable, all may be None
39
+ when the tracer was set without helpers (e.g. in tests)."""
40
+ return _use_span, _status_cls, _status_code
41
+
42
+
43
+ def set_otel_transport(tracer: Any, *, use_span=None, status_cls=None, status_code=None) -> None:
44
+ """Internal: register_otel calls this once mode='otlp' setup completes.
45
+
46
+ Helpers are optional so tests can stub a tracer without installing the
47
+ full opentelemetry stack.
48
+ """
49
+ global _mode, _tracer, _use_span, _status_cls, _status_code
50
+ _mode = "otlp"
51
+ _tracer = tracer
52
+ _use_span = use_span
53
+ _status_cls = status_cls
54
+ _status_code = status_code
55
+
56
+
57
+ def _reset_transport_for_tests() -> None:
58
+ """Reset for tests. Not exported from trodo/__init__.py."""
59
+ global _mode, _tracer, _use_span, _status_cls, _status_code
60
+ _mode = "trodo"
61
+ _tracer = None
62
+ _use_span = None
63
+ _status_cls = None
64
+ _status_code = None
@@ -32,6 +32,30 @@ from typing import Any, Callable, Dict, Optional
32
32
 
33
33
  from .context import ActiveSpanContext, get_active_context, run_with_context
34
34
  from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
35
+ from .transport import get_transport_mode, get_otel_tracer, get_otel_helpers
36
+
37
+
38
+ def _hex_to_uuid(hex_str: str) -> str:
39
+ """Format a 32-char hex (OTel traceId) into a UUID. Mirrors
40
+ backend/agentOtlpController.hexToUuid so the run_id returned to the user
41
+ matches the run_id the backend computes from the OTLP payload."""
42
+ h = hex_str.replace("-", "")
43
+ if len(h) != 32:
44
+ return str(uuid.uuid4())
45
+ return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
46
+
47
+
48
+ def _serialize_attr(value: Any) -> Any:
49
+ """Coerce a Python value into something OTel attribute setters accept
50
+ (str / int / float / bool). Complex objects → JSON string."""
51
+ if value is None:
52
+ return ""
53
+ if isinstance(value, (str, int, float, bool)):
54
+ return value
55
+ try:
56
+ return json.dumps(value, default=str)
57
+ except Exception:
58
+ return str(value)
35
59
 
36
60
 
37
61
  def _now_iso() -> str:
@@ -262,8 +286,27 @@ class wrap_agent:
262
286
  self._started_ms: float = 0.0
263
287
  self._started_iso: str = ""
264
288
  self.handle: Optional[RunHandle] = None
289
+ # OTel-mode state — populated only when transport mode is 'otlp'.
290
+ self._otel_span: Optional[Any] = None
291
+ self._otel_token: Optional[Any] = None
265
292
 
266
293
  def __enter__(self) -> RunHandle:
294
+ # Dispatch: 'otlp' transport routes through OTel tracer so auto-
295
+ # instrumented children (Anthropic / LangChain) join the same trace.
296
+ if get_transport_mode() == "otlp" and get_otel_tracer() is not None:
297
+ return self._enter_otel()
298
+ return self._enter_trodo()
299
+
300
+ def __exit__(self, exc_type, exc, tb) -> None:
301
+ if self._otel_span is not None:
302
+ return self._exit_otel(exc_type, exc, tb)
303
+ return self._exit_trodo(exc_type, exc, tb)
304
+
305
+ # ----------------------------------------------------------------------
306
+ # Legacy 'trodo' transport — TrodoSpanProcessor + Trodo HTTP API path.
307
+ # ----------------------------------------------------------------------
308
+
309
+ def _enter_trodo(self) -> RunHandle:
267
310
  run_id = str(uuid.uuid4())
268
311
  root_span_id = str(uuid.uuid4())
269
312
  self._started_iso = _now_iso()
@@ -281,7 +324,7 @@ class wrap_agent:
281
324
  self._ctx_mgr.__enter__()
282
325
  return self.handle
283
326
 
284
- def __exit__(self, exc_type, exc, tb) -> None:
327
+ def _exit_trodo(self, exc_type, exc, tb) -> None:
285
328
  assert self.handle is not None
286
329
  ended_iso = _now_iso()
287
330
  duration_ms = int(time.time() * 1000.0 - self._started_ms)
@@ -321,6 +364,71 @@ class wrap_agent:
321
364
  self._ctx_mgr.__exit__(exc_type, exc, tb)
322
365
  return None
323
366
 
367
+ # ----------------------------------------------------------------------
368
+ # 'otlp' transport — OTel tracer.start_as_current_span path.
369
+ # ----------------------------------------------------------------------
370
+
371
+ def _enter_otel(self) -> RunHandle:
372
+ tracer = get_otel_tracer()
373
+ otel_span = tracer.start_span(self._agent_name)
374
+ # Make this span the current span for the duration of the with-block
375
+ # so auto-instrumented children attach to it via OTel context. The
376
+ # use_span helper is captured at register_otel time so wrap_agent
377
+ # doesn't need to import opentelemetry at runtime — keeps the test
378
+ # path mockable and the dependency truly optional in 'trodo' mode.
379
+ use_span_helper, _, _ = get_otel_helpers()
380
+ if use_span_helper is not None:
381
+ cm = use_span_helper(otel_span, end_on_exit=False)
382
+ cm.__enter__()
383
+ self._otel_token = cm # store CM so __exit__ can release the context
384
+
385
+ trace_id_int = otel_span.get_span_context().trace_id
386
+ trace_id_hex = format(trace_id_int, "032x") if isinstance(trace_id_int, int) else str(trace_id_int)
387
+ run_id = _hex_to_uuid(trace_id_hex)
388
+
389
+ otel_span.set_attribute("trodo.agent_name", self._agent_name)
390
+ if self._distinct_id:
391
+ otel_span.set_attribute("trodo.distinct_id", str(self._distinct_id))
392
+ if self._conversation_id:
393
+ otel_span.set_attribute("trodo.conversation_id", str(self._conversation_id))
394
+ if self._parent_run_id:
395
+ otel_span.set_attribute("trodo.parent_run_id", str(self._parent_run_id))
396
+ if self._metadata:
397
+ for k, v in self._metadata.items():
398
+ otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
399
+
400
+ self._otel_span = otel_span
401
+ self.handle = RunHandle(run_id, self._agent_name)
402
+ return self.handle
403
+
404
+ def _exit_otel(self, exc_type, exc, tb) -> None:
405
+ assert self.handle is not None
406
+ assert self._otel_span is not None
407
+ otel_span = self._otel_span
408
+ try:
409
+ if self.handle.input is not None:
410
+ otel_span.set_attribute("ai.prompt", self.handle.input)
411
+ if self.handle.output is not None:
412
+ otel_span.set_attribute("ai.response.text", self.handle.output)
413
+ for k, v in self.handle.metadata.items():
414
+ otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
415
+ if exc is not None:
416
+ otel_span.record_exception(exc)
417
+ _, status_cls, status_code = get_otel_helpers()
418
+ if status_cls is not None and status_code is not None:
419
+ otel_span.set_status(status_cls(status_code.ERROR, _truncate(str(exc), 4_000) or ""))
420
+ finally:
421
+ otel_span.end()
422
+ # Best-effort close of the use_span CM if it was created.
423
+ if self._otel_token is not None:
424
+ try:
425
+ self._otel_token.__exit__(exc_type, exc, tb) # type: ignore[union-attr]
426
+ except Exception:
427
+ pass
428
+ self._otel_span = None
429
+ self._otel_token = None
430
+ return None
431
+
324
432
 
325
433
  class join_run:
326
434
  """Join an existing agent run owned by a remote service.
@@ -71,4 +71,5 @@ def build_session_payload(session: ServerSession) -> Dict[str, Any]:
71
71
  "utm_id": None,
72
72
  "visited_pages": [],
73
73
  "active_time_ms": 0,
74
+ "is_server_session": True,
74
75
  }
@@ -1,3 +1,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: trodo-python
3
+ Version: 2.4.0
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: 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"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: pytest-cov; extra == "dev"
29
+ Requires-Dist: responses>=0.25.0; extra == "dev"
30
+ Requires-Dist: httpx>=0.27.0; extra == "dev"
31
+
1
32
  # trodo-python
2
33
 
3
34
  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`.
@@ -10,6 +41,39 @@ pip install trodo-python
10
41
 
11
42
  Requires Python 3.8+.
12
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
+
13
77
  ## Quick Start
14
78
 
15
79
  ```python
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  tests/test_cross_process_session.py
4
4
  tests/test_end_run.py
5
5
  tests/test_processor_methods.py
6
+ tests/test_register_otel.py
6
7
  tests/test_start_run.py
7
8
  tests/test_wrap_agent_unchanged.py
8
9
  trodo/__init__.py
@@ -23,6 +24,8 @@ trodo/otel/auto_instrument.py
23
24
  trodo/otel/context.py
24
25
  trodo/otel/helpers.py
25
26
  trodo/otel/processor.py
27
+ trodo/otel/register.py
28
+ trodo/otel/transport.py
26
29
  trodo/otel/wrap_agent.py
27
30
  trodo/queue/__init__.py
28
31
  trodo/queue/batch_flusher.py
@@ -0,0 +1,15 @@
1
+ requests>=2.28.0
2
+
3
+ [async]
4
+ httpx>=0.27.0
5
+
6
+ [dev]
7
+ pytest>=7.0
8
+ pytest-cov
9
+ responses>=0.25.0
10
+ httpx>=0.27.0
11
+
12
+ [otlp]
13
+ opentelemetry-api>=1.20.0
14
+ opentelemetry-sdk>=1.20.0
15
+ opentelemetry-exporter-otlp-proto-http>=1.20.0
@@ -1,10 +0,0 @@
1
- requests>=2.28.0
2
-
3
- [async]
4
- httpx>=0.27.0
5
-
6
- [dev]
7
- pytest>=7.0
8
- pytest-cov
9
- responses>=0.25.0
10
- httpx>=0.27.0
File without changes