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.
Files changed (48) hide show
  1. {trodo_python-2.10.8 → trodo_python-2.10.10}/PKG-INFO +1 -1
  2. {trodo_python-2.10.8 → trodo_python-2.10.10}/pyproject.toml +1 -1
  3. trodo_python-2.10.10/tests/test_auto_instrument_fixes.py +217 -0
  4. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/__init__.py +1 -1
  5. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/auto_instrument.py +125 -7
  6. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/PKG-INFO +1 -1
  7. trodo_python-2.10.8/tests/test_auto_instrument_fixes.py +0 -111
  8. {trodo_python-2.10.8 → trodo_python-2.10.10}/README.md +0 -0
  9. {trodo_python-2.10.8 → trodo_python-2.10.10}/setup.cfg +0 -0
  10. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_anon_distinct_id 2.py +0 -0
  11. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_anon_distinct_id.py +0 -0
  12. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_cross_process_session.py +0 -0
  13. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_end_run.py +0 -0
  14. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_error_enrichment.py +0 -0
  15. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_llm_usage_cost.py +0 -0
  16. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_processor_methods.py +0 -0
  17. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_register_otel.py +0 -0
  18. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_start_run.py +0 -0
  19. {trodo_python-2.10.8 → trodo_python-2.10.10}/tests/test_wrap_agent_unchanged.py +0 -0
  20. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/api/__init__.py +0 -0
  21. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/api/async_client.py +0 -0
  22. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/api/endpoints.py +0 -0
  23. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/api/http_client.py +0 -0
  24. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/auto/__init__.py +0 -0
  25. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/auto/auto_event_manager.py +0 -0
  26. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/client.py +0 -0
  27. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/managers/__init__.py +0 -0
  28. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/managers/group_manager.py +0 -0
  29. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/managers/people_manager.py +0 -0
  30. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/__init__.py +0 -0
  31. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/context.py +0 -0
  32. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/helpers.py +0 -0
  33. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/processor.py +0 -0
  34. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/register.py +0 -0
  35. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/transport.py +0 -0
  36. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/otel/wrap_agent.py +0 -0
  37. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/queue/__init__.py +0 -0
  38. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/queue/batch_flusher.py +0 -0
  39. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/queue/event_queue.py +0 -0
  40. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/session/__init__.py +0 -0
  41. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/session/server_session.py +0 -0
  42. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/session/session_manager.py +0 -0
  43. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/types.py +0 -0
  44. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo/user_context.py +0 -0
  45. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/SOURCES.txt +0 -0
  46. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/dependency_links.txt +0 -0
  47. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/requires.txt +0 -0
  48. {trodo_python-2.10.8 → trodo_python-2.10.10}/trodo_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.10.8
3
+ Version: 2.10.10
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.10.8"
7
+ version = "2.10.10"
8
8
  description = "Trodo Analytics SDK for Python — server-side event tracking"
9
9
  readme = "README.md"
10
10
  license = { text = "ISC" }
@@ -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
@@ -40,7 +40,7 @@ Downstream microservice (join the caller's run instead of making a new one):
40
40
 
41
41
  from __future__ import annotations
42
42
 
43
- __version__ = "2.10.8"
43
+ __version__ = "2.10.10"
44
44
 
45
45
  from typing import Any, Callable, Dict, List, Optional, Union
46
46
 
@@ -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
- if attrs.get("gen_ai.tool.name"):
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 attrs.get("gen_ai.operation.name") or attrs.get("gen_ai.request.model"):
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
- provider = attrs.get("gen_ai.system") or attrs.get("llm.vendor")
193
- prompt = attrs.get("gen_ai.prompt") or attrs.get("llm.prompts")
194
- completion = attrs.get("gen_ai.completion") or attrs.get("llm.completion")
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=attrs.get("gen_ai.request.temperature"),
216
- tool_name=attrs.get("gen_ai.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.10.8
3
+ Version: 2.10.10
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -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