trodo-python 2.10.8__tar.gz → 2.10.10__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.10.8 → trodo_python-2.10.10}/PKG-INFO +1 -1
- {trodo_python-2.10.8 → trodo_python-2.10.10}/pyproject.toml +1 -1
- trodo_python-2.10.10/tests/test_auto_instrument_fixes.py +217 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/__init__.py +1 -1
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/auto_instrument.py +125 -7
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/PKG-INFO +1 -1
- trodo_python-2.10.8/tests/test_auto_instrument_fixes.py +0 -111
- {trodo_python-2.10.8 → trodo_python-2.10.10}/README.md +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/setup.cfg +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_anon_distinct_id 2.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_end_run.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_error_enrichment.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_register_otel.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_start_run.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/api/__init__.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/api/async_client.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/api/http_client.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/client.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/context.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/processor.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/register.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/transport.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/wrap_agent.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/session/__init__.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/session/server_session.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/types.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/user_context.py +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/SOURCES.txt +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
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
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestVercelAiContentMapping:
|
|
115
|
+
"""Regression: the bridge read input/output only from gen_ai.*/llm.*, so
|
|
116
|
+
Vercel AI SDK spans (ai.* convention) landed with input/output = None and
|
|
117
|
+
the prompt/response stranded in attributes -> nothing embedded or scored."""
|
|
118
|
+
|
|
119
|
+
def _span(self, name, attrs):
|
|
120
|
+
s = _fake_otel_span(start_ns=1_000_000_000, end_ns=2_000_000_000, attrs=attrs)
|
|
121
|
+
s.name = name
|
|
122
|
+
return s
|
|
123
|
+
|
|
124
|
+
def test_maps_ai_prompt_messages_and_response_text(self):
|
|
125
|
+
otel = self._span(
|
|
126
|
+
"ai.streamText.doStream",
|
|
127
|
+
{
|
|
128
|
+
"trodo.run_id": RUN_ID,
|
|
129
|
+
"gen_ai.system": "openrouter",
|
|
130
|
+
"ai.model.id": "gemini-2.5-flash-lite",
|
|
131
|
+
"ai.usage.promptTokens": 766,
|
|
132
|
+
"ai.usage.completionTokens": 5,
|
|
133
|
+
"ai.prompt.messages": '[{"role":"user","content":"gaming monitor specs"}]',
|
|
134
|
+
"ai.response.text": "Here are some gaming monitors...",
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
span = otel_span_to_trodo_span(otel)
|
|
138
|
+
assert span is not None
|
|
139
|
+
assert span.kind == "llm"
|
|
140
|
+
assert "gaming monitor specs" in span.input
|
|
141
|
+
assert "gaming monitors" in span.output
|
|
142
|
+
assert span.model == "gemini-2.5-flash-lite"
|
|
143
|
+
assert span.input_tokens == 766
|
|
144
|
+
assert span.output_tokens == 5
|
|
145
|
+
|
|
146
|
+
def test_maps_ai_toolcall_args_and_result(self):
|
|
147
|
+
otel = self._span(
|
|
148
|
+
"ai.toolCall",
|
|
149
|
+
{
|
|
150
|
+
"trodo.run_id": RUN_ID,
|
|
151
|
+
"ai.toolCall.name": "create_object",
|
|
152
|
+
"ai.toolCall.args": '{"isValid":true}',
|
|
153
|
+
"ai.toolCall.result": '{"ok":1}',
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
span = otel_span_to_trodo_span(otel)
|
|
157
|
+
assert span is not None
|
|
158
|
+
assert span.kind == "tool"
|
|
159
|
+
assert span.tool_name == "create_object"
|
|
160
|
+
assert span.input == '{"isValid":true}'
|
|
161
|
+
assert span.output == '{"ok":1}'
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestCrossConventionContent:
|
|
165
|
+
"""Convention-agnostic capture: OpenLLMetry (raw Anthropic/OpenAI/LangChain)
|
|
166
|
+
and OpenInference use indexed attrs; earlier only single-value keys were read
|
|
167
|
+
so those spans landed with empty input/output."""
|
|
168
|
+
|
|
169
|
+
def _span(self, attrs):
|
|
170
|
+
s = _fake_otel_span(start_ns=1_000_000_000, end_ns=2_000_000_000, attrs={"trodo.run_id": RUN_ID, **attrs})
|
|
171
|
+
s.name = "llm"
|
|
172
|
+
return s
|
|
173
|
+
|
|
174
|
+
def test_openllmetry_indexed(self):
|
|
175
|
+
s = otel_span_to_trodo_span(self._span({
|
|
176
|
+
"gen_ai.prompt.0.role": "system", "gen_ai.prompt.0.content": "be nice",
|
|
177
|
+
"gen_ai.prompt.1.role": "user", "gen_ai.prompt.1.content": "hi",
|
|
178
|
+
"gen_ai.completion.0.role": "assistant", "gen_ai.completion.0.content": "hello",
|
|
179
|
+
}))
|
|
180
|
+
assert "be nice" in s.input and "hi" in s.input
|
|
181
|
+
assert "hello" in s.output
|
|
182
|
+
|
|
183
|
+
def test_openinference_indexed(self):
|
|
184
|
+
s = otel_span_to_trodo_span(self._span({
|
|
185
|
+
"llm.input_messages.0.message.role": "user",
|
|
186
|
+
"llm.input_messages.0.message.content": "question",
|
|
187
|
+
"llm.output_messages.0.message.content": "reply",
|
|
188
|
+
}))
|
|
189
|
+
assert "question" in s.input
|
|
190
|
+
assert "reply" in s.output
|
|
191
|
+
|
|
192
|
+
def test_openinference_value_keys(self):
|
|
193
|
+
s = otel_span_to_trodo_span(self._span({"input.value": "the input", "output.value": "the output"}))
|
|
194
|
+
assert s.input == "the input"
|
|
195
|
+
assert s.output == "the output"
|
|
196
|
+
|
|
197
|
+
def test_otel_current_messages(self):
|
|
198
|
+
s = otel_span_to_trodo_span(self._span({
|
|
199
|
+
"gen_ai.input.messages": '[{"role":"user","parts":[{"content":"q"}]}]',
|
|
200
|
+
"gen_ai.output.messages": '[{"role":"assistant","parts":[{"content":"a"}]}]',
|
|
201
|
+
}))
|
|
202
|
+
assert '"q"' in s.input and '"a"' in s.output
|
|
203
|
+
|
|
204
|
+
def test_vercel_toolcalls_output(self):
|
|
205
|
+
s = otel_span_to_trodo_span(self._span({
|
|
206
|
+
"ai.prompt.messages": '[{"role":"user","content":"eval"}]',
|
|
207
|
+
"ai.response.toolCalls": '[{"toolName":"create_object"}]',
|
|
208
|
+
}))
|
|
209
|
+
assert "create_object" in s.output
|
|
210
|
+
|
|
211
|
+
def test_langfuse_traceloop_single(self):
|
|
212
|
+
s = otel_span_to_trodo_span(self._span({
|
|
213
|
+
"langfuse.observation.input": "user asked X",
|
|
214
|
+
"traceloop.entity.output": '{"result":"Y"}',
|
|
215
|
+
}))
|
|
216
|
+
assert s.input == "user asked X"
|
|
217
|
+
assert "Y" in s.output
|
|
@@ -45,9 +45,19 @@ def _trunc(s: Any, max_len: int) -> Optional[str]:
|
|
|
45
45
|
def _infer_kind(attrs: Dict[str, Any]) -> str:
|
|
46
46
|
if not attrs:
|
|
47
47
|
return "generic"
|
|
48
|
-
|
|
48
|
+
# Vercel AI SDK tool spans (`ai.toolCall`) carry ai.toolCall.name, not
|
|
49
|
+
# gen_ai.tool.name — without this they landed as 'generic'.
|
|
50
|
+
if attrs.get("gen_ai.tool.name") or attrs.get("ai.toolCall.name") or attrs.get("ai.functionCall.name"):
|
|
49
51
|
return "tool"
|
|
50
|
-
if
|
|
52
|
+
if (
|
|
53
|
+
attrs.get("gen_ai.operation.name")
|
|
54
|
+
or attrs.get("gen_ai.request.model")
|
|
55
|
+
or attrs.get("gen_ai.response.model")
|
|
56
|
+
or attrs.get("ai.model.id")
|
|
57
|
+
or attrs.get("ai.response.model")
|
|
58
|
+
or attrs.get("gen_ai.usage.input_tokens") is not None
|
|
59
|
+
or attrs.get("ai.usage.promptTokens") is not None
|
|
60
|
+
):
|
|
51
61
|
return "llm"
|
|
52
62
|
if attrs.get("db.system") or attrs.get("retrieval.query"):
|
|
53
63
|
return "retrieval"
|
|
@@ -57,6 +67,99 @@ def _infer_kind(attrs: Dict[str, Any]) -> str:
|
|
|
57
67
|
_ATTR_TRODO_RUN_ID = "trodo.run_id"
|
|
58
68
|
_ATTR_TRODO_PARENT_SPAN_ID = "trodo.parent_span_id"
|
|
59
69
|
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Convention-agnostic input/output extraction — covers every common LLM
|
|
72
|
+
# instrumentor convention, not just Vercel AI. Mirrors the backend's
|
|
73
|
+
# services/ingest/spanContent.js.
|
|
74
|
+
# - Vercel AI ...... ai.prompt(.messages) / ai.response.text|object|toolCalls,
|
|
75
|
+
# ai.toolCall.args / ai.toolCall.result
|
|
76
|
+
# - OTel GenAI ..... gen_ai.input.messages / gen_ai.output.messages (current);
|
|
77
|
+
# gen_ai.prompt / gen_ai.completion (legacy single value)
|
|
78
|
+
# - OpenLLMetry .... INDEXED gen_ai.prompt.{i}.role/content, gen_ai.completion.{i}.*
|
|
79
|
+
# - OpenInference .. input.value / output.value + INDEXED llm.input_messages.{i}.message.*
|
|
80
|
+
# - Traceloop/Langfuse/MLflow, plus generic llm.prompts / input / prompt
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
_INPUT_SINGLE_KEYS = [
|
|
83
|
+
"gen_ai.input.messages", "ai.prompt.messages", "ai.prompt", "gen_ai.prompt",
|
|
84
|
+
"input.value", "traceloop.entity.input", "langfuse.observation.input", "mlflow.spanInputs",
|
|
85
|
+
"llm.input_messages", "llm.prompts", "gen_ai.tool.input", "ai.toolCall.args", "input", "prompt",
|
|
86
|
+
]
|
|
87
|
+
_OUTPUT_SINGLE_KEYS = [
|
|
88
|
+
"gen_ai.output.messages", "ai.response.text", "ai.response.object", "ai.response.toolCalls",
|
|
89
|
+
"gen_ai.completion", "output.value", "traceloop.entity.output", "langfuse.observation.output",
|
|
90
|
+
"mlflow.spanOutputs", "llm.output_messages", "llm.completions", "gen_ai.tool.output",
|
|
91
|
+
"ai.toolCall.result", "output", "completion",
|
|
92
|
+
]
|
|
93
|
+
_INPUT_INDEXED_BASES = ["gen_ai.prompt", "llm.input_messages"]
|
|
94
|
+
_OUTPUT_INDEXED_BASES = ["gen_ai.completion", "llm.output_messages"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _first_single(attrs: Dict[str, Any], keys) -> Optional[Any]:
|
|
98
|
+
for k in keys:
|
|
99
|
+
v = attrs.get(k)
|
|
100
|
+
if v is None:
|
|
101
|
+
continue
|
|
102
|
+
if isinstance(v, str):
|
|
103
|
+
if v:
|
|
104
|
+
return v
|
|
105
|
+
continue
|
|
106
|
+
return v # dict/list — keep structure
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _set_nested(obj: Dict[str, Any], path: str, val: Any) -> None:
|
|
111
|
+
parts = path.split(".")
|
|
112
|
+
cur = obj
|
|
113
|
+
for p in parts[:-1]:
|
|
114
|
+
nxt = cur.get(p)
|
|
115
|
+
if not isinstance(nxt, dict):
|
|
116
|
+
nxt = {}
|
|
117
|
+
cur[p] = nxt
|
|
118
|
+
cur = nxt
|
|
119
|
+
cur[parts[-1]] = val
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _reconstruct_indexed(attrs: Dict[str, Any], bases) -> Optional[str]:
|
|
123
|
+
import json as _json
|
|
124
|
+
for base in bases:
|
|
125
|
+
prefix = base + "."
|
|
126
|
+
by_index: Dict[int, Dict[str, Any]] = {}
|
|
127
|
+
for key, val in attrs.items():
|
|
128
|
+
if not key.startswith(prefix):
|
|
129
|
+
continue
|
|
130
|
+
rest = key[len(prefix):]
|
|
131
|
+
dot = rest.find(".")
|
|
132
|
+
if dot < 0:
|
|
133
|
+
continue
|
|
134
|
+
idx_str = rest[:dot]
|
|
135
|
+
if not idx_str.isdigit():
|
|
136
|
+
continue
|
|
137
|
+
idx = int(idx_str)
|
|
138
|
+
by_index.setdefault(idx, {})
|
|
139
|
+
_set_nested(by_index[idx], rest[dot + 1:], val)
|
|
140
|
+
if by_index:
|
|
141
|
+
arr = [by_index[i] for i in sorted(by_index)]
|
|
142
|
+
return _json.dumps(arr)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _extract_input(attrs: Dict[str, Any]) -> Optional[Any]:
|
|
147
|
+
if not isinstance(attrs, dict):
|
|
148
|
+
return None
|
|
149
|
+
single = _first_single(attrs, _INPUT_SINGLE_KEYS)
|
|
150
|
+
if single is not None:
|
|
151
|
+
return single
|
|
152
|
+
return _reconstruct_indexed(attrs, _INPUT_INDEXED_BASES)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _extract_output(attrs: Dict[str, Any]) -> Optional[Any]:
|
|
156
|
+
if not isinstance(attrs, dict):
|
|
157
|
+
return None
|
|
158
|
+
single = _first_single(attrs, _OUTPUT_SINGLE_KEYS)
|
|
159
|
+
if single is not None:
|
|
160
|
+
return single
|
|
161
|
+
return _reconstruct_indexed(attrs, _OUTPUT_INDEXED_BASES)
|
|
162
|
+
|
|
60
163
|
|
|
61
164
|
def _span_id_to_uuid(span_id: Optional[str]) -> Optional[str]:
|
|
62
165
|
"""Convert an OTel hex span id into UUID format. Mirrors trodo-node's
|
|
@@ -178,20 +281,35 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
|
|
|
178
281
|
attrs.get("gen_ai.usage.input_tokens")
|
|
179
282
|
or attrs.get("gen_ai.usage.prompt_tokens")
|
|
180
283
|
or attrs.get("llm.usage.prompt_tokens")
|
|
284
|
+
or attrs.get("ai.usage.promptTokens")
|
|
285
|
+
or attrs.get("ai.usage.inputTokens")
|
|
181
286
|
)
|
|
182
287
|
out_toks = (
|
|
183
288
|
attrs.get("gen_ai.usage.output_tokens")
|
|
184
289
|
or attrs.get("gen_ai.usage.completion_tokens")
|
|
185
290
|
or attrs.get("llm.usage.completion_tokens")
|
|
291
|
+
or attrs.get("ai.usage.completionTokens")
|
|
292
|
+
or attrs.get("ai.usage.outputTokens")
|
|
186
293
|
)
|
|
187
294
|
model = (
|
|
188
295
|
attrs.get("gen_ai.request.model")
|
|
189
296
|
or attrs.get("gen_ai.response.model")
|
|
190
297
|
or attrs.get("llm.request.model")
|
|
298
|
+
or attrs.get("ai.model.id")
|
|
299
|
+
or attrs.get("ai.response.model")
|
|
300
|
+
)
|
|
301
|
+
provider = attrs.get("gen_ai.system") or attrs.get("llm.vendor") or attrs.get("ai.model.provider")
|
|
302
|
+
# Convention-agnostic — see _extract_input/_extract_output above.
|
|
303
|
+
prompt = _extract_input(attrs)
|
|
304
|
+
completion = _extract_output(attrs)
|
|
305
|
+
tool_name = (
|
|
306
|
+
attrs.get("gen_ai.tool.name")
|
|
307
|
+
or attrs.get("ai.toolCall.name")
|
|
308
|
+
or attrs.get("ai.functionCall.name")
|
|
191
309
|
)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
310
|
+
temperature = attrs.get("gen_ai.request.temperature")
|
|
311
|
+
if temperature is None:
|
|
312
|
+
temperature = attrs.get("ai.settings.temperature")
|
|
195
313
|
|
|
196
314
|
return TrodoSpan(
|
|
197
315
|
span_id=span_id,
|
|
@@ -212,8 +330,8 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
|
|
|
212
330
|
provider=provider,
|
|
213
331
|
input_tokens=int(in_toks) if in_toks is not None else None,
|
|
214
332
|
output_tokens=int(out_toks) if out_toks is not None else None,
|
|
215
|
-
temperature=
|
|
216
|
-
tool_name=
|
|
333
|
+
temperature=temperature,
|
|
334
|
+
tool_name=tool_name,
|
|
217
335
|
input=prompt,
|
|
218
336
|
output=completion,
|
|
219
337
|
attributes=attrs or None,
|
|
@@ -1,111 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|