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.
- {trodo_python-2.3.1/trodo_python.egg-info → trodo_python-2.4.1}/PKG-INFO +38 -1
- trodo_python-2.3.1/PKG-INFO → trodo_python-2.4.1/README.md +33 -27
- {trodo_python-2.3.1 → trodo_python-2.4.1}/pyproject.toml +8 -1
- trodo_python-2.4.1/tests/test_auto_instrument_fixes.py +111 -0
- trodo_python-2.4.1/tests/test_register_otel.py +183 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/__init__.py +46 -1
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/client.py +19 -1
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/auto_instrument.py +54 -12
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/helpers.py +9 -4
- trodo_python-2.4.1/trodo/otel/register.py +200 -0
- trodo_python-2.4.1/trodo/otel/transport.py +64 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/wrap_agent.py +109 -1
- trodo_python-2.3.1/README.md → trodo_python-2.4.1/trodo_python.egg-info/PKG-INFO +64 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo_python.egg-info/SOURCES.txt +4 -0
- trodo_python-2.4.1/trodo_python.egg-info/requires.txt +15 -0
- trodo_python-2.3.1/trodo_python.egg-info/requires.txt +0 -10
- {trodo_python-2.3.1 → trodo_python-2.4.1}/setup.cfg +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_end_run.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_start_run.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/api/__init__.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/api/async_client.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/api/http_client.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/context.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/otel/processor.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/session/__init__.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/session/server_session.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/types.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo/user_context.py +0 -0
- {trodo_python-2.3.1 → trodo_python-2.4.1}/trodo_python.egg-info/dependency_links.txt +0 -0
- {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
|
+
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
75
|
+
otel_parent_span_id: Optional[str] = None
|
|
58
76
|
if parent is not None:
|
|
59
77
|
pid = getattr(parent, "span_id", None)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
otel_parent_span_id = (
|
|
79
|
+
f"{pid:016x}" if isinstance(pid, int) else str(pid) if pid else None
|
|
80
|
+
)
|
|
63
81
|
|
|
64
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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)
|