trodo-python 2.21.0__tar.gz → 2.22.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 (66) hide show
  1. {trodo_python-2.21.0 → trodo_python-2.22.0}/PKG-INFO +1 -1
  2. {trodo_python-2.21.0 → trodo_python-2.22.0}/pyproject.toml +1 -1
  3. trodo_python-2.22.0/tests/test_instrumentor_resolution.py +109 -0
  4. trodo_python-2.22.0/tests/test_prompt_trace.py +261 -0
  5. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/otel/auto_instrument.py +58 -53
  6. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/otel/prompt_trace.py +65 -3
  7. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/otel/wrap_agent.py +53 -7
  8. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/prompts/types.py +21 -12
  9. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo_python.egg-info/PKG-INFO +1 -1
  10. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo_python.egg-info/SOURCES.txt +1 -0
  11. trodo_python-2.21.0/tests/test_prompt_trace.py +0 -107
  12. {trodo_python-2.21.0 → trodo_python-2.22.0}/README.md +0 -0
  13. {trodo_python-2.21.0 → trodo_python-2.22.0}/setup.cfg +0 -0
  14. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_anon_distinct_id.py +0 -0
  15. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_auto_instrument_fixes.py +0 -0
  16. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_cross_process_session.py +0 -0
  17. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_datasets.py +0 -0
  18. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_end_run.py +0 -0
  19. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_error_enrichment.py +0 -0
  20. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_experiments.py +0 -0
  21. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_get_prompt_version_types.py +0 -0
  22. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_llm_usage_cost.py +0 -0
  23. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_multi_agent_shape.py +0 -0
  24. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_processor_methods.py +0 -0
  25. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_prompts.py +0 -0
  26. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_register_otel.py +0 -0
  27. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_start_run.py +0 -0
  28. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_user_traits.py +0 -0
  29. {trodo_python-2.21.0 → trodo_python-2.22.0}/tests/test_wrap_agent_unchanged.py +0 -0
  30. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/__init__.py +0 -0
  31. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/api/__init__.py +0 -0
  32. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/api/async_client.py +0 -0
  33. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/api/endpoints.py +0 -0
  34. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/api/http_client.py +0 -0
  35. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/auto/__init__.py +0 -0
  36. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/auto/auto_event_manager.py +0 -0
  37. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/client.py +0 -0
  38. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/managers/__init__.py +0 -0
  39. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/managers/dataset_manager.py +0 -0
  40. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/managers/experiment_manager.py +0 -0
  41. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/managers/group_manager.py +0 -0
  42. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/managers/people_manager.py +0 -0
  43. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/managers/prompt_manager.py +0 -0
  44. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/managers/user_manager.py +0 -0
  45. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/otel/__init__.py +0 -0
  46. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/otel/context.py +0 -0
  47. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/otel/helpers.py +0 -0
  48. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/otel/processor.py +0 -0
  49. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/otel/register.py +0 -0
  50. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/otel/transport.py +0 -0
  51. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/prompts/__init__.py +0 -0
  52. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/prompts/compile.py +0 -0
  53. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/prompts/template.py +0 -0
  54. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/queue/__init__.py +0 -0
  55. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/queue/batch_flusher.py +0 -0
  56. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/queue/event_queue.py +0 -0
  57. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/session/__init__.py +0 -0
  58. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/session/server_session.py +0 -0
  59. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/session/session_manager.py +0 -0
  60. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/types.py +0 -0
  61. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/user_context.py +0 -0
  62. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/util/__init__.py +0 -0
  63. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo/util/lru.py +0 -0
  64. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  65. {trodo_python-2.21.0 → trodo_python-2.22.0}/trodo_python.egg-info/requires.txt +0 -0
  66. {trodo_python-2.21.0 → trodo_python-2.22.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.21.0
3
+ Version: 2.22.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.21.0"
7
+ version = "2.22.0"
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,109 @@
1
+ """Instrumentor class resolution must survive upstream renames.
2
+
3
+ Every per-framework instrumentor is loaded by name, and the loader in
4
+ ``enable_auto_instrument`` swallows failures so a missing optional package
5
+ never breaks user code. The cost of that tolerance: a RENAMED export is
6
+ indistinguishable from an absent package. The user installs exactly the
7
+ package we documented, gets zero spans, and gets no error explaining why.
8
+
9
+ That is not hypothetical — it happened twice. ``google-generativeai`` renamed
10
+ its class, and ``opentelemetry-instrumentation-langchain`` ships
11
+ ``LangchainInstrumentor`` while we imported ``LangChainInstrumentor``, which
12
+ silently disabled LangChain tracing for every Python user on a current version.
13
+
14
+ So resolution tries the known spellings and then falls back to whatever single
15
+ ``*Instrumentor`` the module exports.
16
+ """
17
+ import sys
18
+ import types
19
+
20
+ import pytest
21
+
22
+ from trodo.otel.auto_instrument import _instrument
23
+
24
+
25
+ def _fake_module(name, **attrs):
26
+ mod = types.ModuleType(name)
27
+ for k, v in attrs.items():
28
+ setattr(mod, k, v)
29
+ sys.modules[name] = mod
30
+ return mod
31
+
32
+
33
+ class _Recorder:
34
+ instrumented = False
35
+
36
+ def instrument(self):
37
+ type(self).instrumented = True
38
+
39
+
40
+ @pytest.fixture(autouse=True)
41
+ def _clean():
42
+ made = []
43
+ yield made
44
+ for n in made:
45
+ sys.modules.pop(n, None)
46
+
47
+
48
+ def test_uses_the_preferred_name_when_present(_clean):
49
+ name = 'fake_instr_preferred'
50
+ _clean.append(name)
51
+ cls = type('WantedInstrumentor', (_Recorder,), {'instrumented': False})
52
+ _fake_module(name, WantedInstrumentor=cls, OtherInstrumentor=type('OtherInstrumentor', (_Recorder,), {}))
53
+ _instrument(name, 'WantedInstrumentor')
54
+ assert cls.instrumented is True
55
+
56
+
57
+ def test_tries_each_spelling_in_order(_clean):
58
+ """The real LangChain case: we ask for both casings, upstream ships one."""
59
+ name = 'fake_instr_langchain'
60
+ _clean.append(name)
61
+ cls = type('LangchainInstrumentor', (_Recorder,), {'instrumented': False})
62
+ _fake_module(name, LangchainInstrumentor=cls)
63
+ # 'LangChainInstrumentor' (capital C) does not exist here — must not raise.
64
+ _instrument(name, 'LangchainInstrumentor', 'LangChainInstrumentor')
65
+ assert cls.instrumented is True
66
+
67
+
68
+ def test_falls_back_to_the_only_exported_instrumentor(_clean):
69
+ """An unforeseen rename still works, which is the whole point."""
70
+ name = 'fake_instr_renamed'
71
+ _clean.append(name)
72
+ cls = type('SomethingWeNeverHeardOfInstrumentor', (_Recorder,), {'instrumented': False})
73
+ _fake_module(name, SomethingWeNeverHeardOfInstrumentor=cls)
74
+ _instrument(name, 'ExpectedInstrumentor')
75
+ assert cls.instrumented is True
76
+
77
+
78
+ def test_refuses_to_guess_between_several_candidates(_clean):
79
+ """Picking arbitrarily could instrument the wrong thing — say so instead."""
80
+ name = 'fake_instr_ambiguous'
81
+ _clean.append(name)
82
+ _fake_module(name,
83
+ AInstrumentor=type('AInstrumentor', (_Recorder,), {}),
84
+ BInstrumentor=type('BInstrumentor', (_Recorder,), {}))
85
+ with pytest.raises(ImportError, match='several Instrumentor classes'):
86
+ _instrument(name, 'NopeInstrumentor')
87
+
88
+
89
+ def test_reports_when_nothing_is_exported(_clean):
90
+ name = 'fake_instr_empty'
91
+ _clean.append(name)
92
+ _fake_module(name)
93
+ with pytest.raises(ImportError, match='no Instrumentor class'):
94
+ _instrument(name, 'AnyInstrumentor')
95
+
96
+
97
+ def test_base_instrumentor_is_never_the_fallback(_clean):
98
+ """Modules re-export BaseInstrumentor; instrumenting it would be wrong."""
99
+ name = 'fake_instr_baseonly'
100
+ _clean.append(name)
101
+ _fake_module(name, BaseInstrumentor=type('BaseInstrumentor', (_Recorder,), {}))
102
+ with pytest.raises(ImportError, match='no Instrumentor class'):
103
+ _instrument(name, 'AnyInstrumentor')
104
+
105
+
106
+ def test_a_missing_module_still_raises_importerror(_clean):
107
+ """The caller relies on ImportError to mean "skip this framework"."""
108
+ with pytest.raises(ImportError):
109
+ _instrument('module_that_does_not_exist_xyz', 'AnyInstrumentor')
@@ -0,0 +1,261 @@
1
+ """Prompt -> span traceability (mirrors the Node promptTrace.test.ts).
2
+
3
+ Compiling a managed prompt must stamp the exact version (by immutable hash)
4
+ onto the spans that used it, and aggregate every prompt used onto the run.
5
+
6
+ Two routes get it there, and both are load-bearing:
7
+ - the RUN SCOPE, when compile() runs inside wrap_agent/span. The only route
8
+ that reaches auto-instrumented spans, whose input we never see.
9
+ - the COMPILED VALUE, when those messages are passed to a span as ``input``.
10
+ The only route that survives compiling outside the run, which is an
11
+ ordinary shape and used to produce no link at all.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from trodo.managers.prompt_manager import PromptManager
16
+ from trodo.otel.wrap_agent import span, wrap_agent
17
+
18
+ PROMPT_WIRE = {
19
+ "name": "refund-agent",
20
+ "version": 3,
21
+ "version_hash": "a3f9c2e8d1b04756",
22
+ "content_hash": "ccccontent1234",
23
+ "parent_hash": "pppparent5678",
24
+ "labels": ["production"],
25
+ "tags": [],
26
+ "messages": [{"role": "system", "content": [{"type": "text", "text": "Help {{q}}"}]}],
27
+ "model": {"provider": "openai", "model": "gpt-4o"},
28
+ "tools": [],
29
+ "response_format": None,
30
+ "variables": [{"name": "q", "type": "string"}],
31
+ "updated_at": "2026-07-01T00:00:00Z",
32
+ }
33
+
34
+
35
+ class PromptHttp:
36
+ def __init__(self, prompt=None, fail=False):
37
+ self._prompt = prompt
38
+ self.fail = fail
39
+
40
+ def get_prompt(self, name, label=None, version=None):
41
+ if self.fail:
42
+ raise RuntimeError("unreachable")
43
+ return {"prompt": dict(self._prompt)}
44
+
45
+
46
+ def test_stamps_version_hash_on_span_and_aggregates_on_run(processor, http):
47
+ mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
48
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
49
+ p = mgr.get("refund-agent", cache_ttl_seconds=0)
50
+ with span("llm", kind="llm"):
51
+ p.compile(q="my order")
52
+
53
+ ingest = http.run_ingest[0]
54
+ spans = ingest["spans"]
55
+ llm = next(s for s in spans if s["name"] == "llm")
56
+ attrs = llm["attributes"]
57
+ assert attrs["trodo.prompt.name"] == "refund-agent"
58
+ assert attrs["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
59
+ assert attrs["trodo.prompt.label"] == "production" # default selector
60
+ assert attrs["trodo.prompt.content_hash"] == "ccccontent1234"
61
+
62
+ used = ingest["run"]["attributes"]["trodo.prompts"]
63
+ assert len(used) == 1
64
+ assert used[0]["version_hash"] == "a3f9c2e8d1b04756"
65
+
66
+
67
+ def test_records_label_and_omits_it_for_version_pin(processor, http):
68
+ mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
69
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
70
+ staged = mgr.get("refund-agent", label="staging", cache_ttl_seconds=0)
71
+ with span("s1"):
72
+ staged.compile(q="x")
73
+ pinned = mgr.get("refund-agent", version="a3f9c2", cache_ttl_seconds=0)
74
+ with span("s2"):
75
+ pinned.compile(q="y")
76
+
77
+ spans = http.run_ingest[0]["spans"]
78
+ s1 = next(s for s in spans if s["name"] == "s1")["attributes"]
79
+ s2 = next(s for s in spans if s["name"] == "s2")["attributes"]
80
+ assert s1["trodo.prompt.label"] == "staging"
81
+ assert "trodo.prompt.label" not in s2 # pinned by hash -> no label
82
+ assert s2["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
83
+
84
+
85
+ def test_no_op_outside_a_run(processor, http):
86
+ mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
87
+ p = mgr.get("refund-agent", cache_ttl_seconds=0)
88
+ # Must not raise despite there being no active run context.
89
+ p.compile(q="z")
90
+ assert len(http.run_ingest) == 0
91
+
92
+
93
+ def test_fallback_prompt_is_not_traced(processor, http):
94
+ mgr = PromptManager(PromptHttp(fail=True))
95
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
96
+ p = mgr.get(
97
+ "refund-agent",
98
+ cache_ttl_seconds=0,
99
+ max_retries=0,
100
+ fallback={
101
+ "messages": [{"role": "user", "content": [{"type": "text", "text": "hi {{q}}"}]}],
102
+ "variables": [{"name": "q"}],
103
+ },
104
+ )
105
+ assert p.is_fallback is True
106
+ with span("llm"):
107
+ p.compile(q="x")
108
+
109
+ spans = http.run_ingest[0]["spans"]
110
+ attrs = next(s for s in spans if s["name"] == "llm").get("attributes") or {}
111
+ assert "trodo.prompt.name" not in attrs
112
+ # to_dict() drops None, so an untraced run has no 'attributes' key at all.
113
+ assert "attributes" not in http.run_ingest[0]["run"]
114
+
115
+
116
+ # ── compiled OUTSIDE the run ────────────────────────────────────────────────
117
+ #
118
+ # Fetch and compile up front, open the run around just the model call. Under the
119
+ # run-scope mechanism alone this produced no link whatsoever.
120
+
121
+
122
+ def test_links_span_whose_input_was_compiled_outside_the_run(processor, http):
123
+ mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
124
+ p = mgr.get("refund-agent", cache_ttl_seconds=0)
125
+ compiled = p.compile(q="z") # OUTSIDE any run
126
+
127
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
128
+ with span("llm", kind="llm", input=compiled.messages):
129
+ pass
130
+
131
+ attrs = next(s for s in http.run_ingest[0]["spans"] if s["name"] == "llm")["attributes"]
132
+ assert attrs["trodo.prompt.name"] == "refund-agent"
133
+ assert attrs["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
134
+
135
+
136
+ def test_outside_compiled_prompt_reaches_the_run_level_list(processor, http):
137
+ mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
138
+ p = mgr.get("refund-agent", cache_ttl_seconds=0)
139
+ compiled = p.compile(q="z")
140
+
141
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
142
+ with span("llm", kind="llm", input=compiled.messages):
143
+ pass
144
+
145
+ used = http.run_ingest[0]["run"]["attributes"]["trodo.prompts"]
146
+ assert [r["name"] for r in used] == ["refund-agent"]
147
+
148
+
149
+ def test_the_compiled_wrapper_works_as_input_too(processor, http):
150
+ mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
151
+ p = mgr.get("refund-agent", cache_ttl_seconds=0)
152
+ compiled = p.compile(q="z")
153
+
154
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
155
+ with span("llm", kind="llm", input=compiled):
156
+ pass
157
+
158
+ attrs = next(s for s in http.run_ingest[0]["spans"] if s["name"] == "llm")["attributes"]
159
+ assert attrs["trodo.prompt.name"] == "refund-agent"
160
+
161
+
162
+ def test_span_input_beats_the_runs_most_recent_prompt(processor, http):
163
+ """Specificity: a span holding prompt A must say A, not whatever came last."""
164
+ other = dict(PROMPT_WIRE, name="other-agent", version_hash="bbbb2222", content_hash="ccc2")
165
+
166
+ class TwoPrompts:
167
+ def get_prompt(self, name, label=None, version=None):
168
+ return {"prompt": dict(other if name == "other-agent" else PROMPT_WIRE)}
169
+
170
+ mgr = PromptManager(TwoPrompts())
171
+ a = mgr.get("refund-agent", cache_ttl_seconds=0).compile(q="first")
172
+
173
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
174
+ # Compiling B inside the run makes B the run's "current" prompt.
175
+ mgr.get("other-agent", cache_ttl_seconds=0).compile(q="second")
176
+ with span("llm", kind="llm", input=a.messages):
177
+ pass
178
+
179
+ attrs = next(s for s in http.run_ingest[0]["spans"] if s["name"] == "llm")["attributes"]
180
+ assert attrs["trodo.prompt.name"] == "refund-agent"
181
+ assert attrs["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
182
+
183
+
184
+ def test_a_span_that_never_saw_a_prompt_is_not_labelled(processor, http):
185
+ """False-positive guard: attribution must not leak to sibling spans."""
186
+ mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
187
+ p = mgr.get("refund-agent", cache_ttl_seconds=0)
188
+ compiled = p.compile(q="z")
189
+
190
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
191
+ with span("llm", kind="llm", input=compiled.messages):
192
+ pass
193
+ with span("unrelated", input={"some": "payload"}):
194
+ pass
195
+
196
+ attrs = next(s for s in http.run_ingest[0]["spans"] if s["name"] == "unrelated").get("attributes") or {}
197
+ assert "trodo.prompt.name" not in attrs
198
+
199
+
200
+ def test_hand_written_messages_are_not_carriers(processor, http):
201
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
202
+ with span("llm", kind="llm", input=[{"role": "user", "content": "hand-written"}]):
203
+ pass
204
+
205
+ attrs = next(s for s in http.run_ingest[0]["spans"] if s["name"] == "llm").get("attributes") or {}
206
+ assert "trodo.prompt.name" not in attrs
207
+
208
+
209
+ def test_a_fallback_is_never_tagged_via_the_input_route(processor, http):
210
+ mgr = PromptManager(PromptHttp(fail=True))
211
+ p = mgr.get(
212
+ "refund-agent",
213
+ fallback={"messages": [{"role": "user", "content": [{"type": "text", "text": "hi {{q}}"}]}],
214
+ "variables": [{"name": "q"}]},
215
+ cache_ttl_seconds=0,
216
+ )
217
+ assert p.is_fallback
218
+ compiled = p.compile(q="x")
219
+
220
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
221
+ with span("llm", kind="llm", input=compiled.messages):
222
+ pass
223
+
224
+ attrs = next(s for s in http.run_ingest[0]["spans"] if s["name"] == "llm").get("attributes") or {}
225
+ assert "trodo.prompt.name" not in attrs
226
+
227
+
228
+ def test_carrier_table_is_bounded(processor, http):
229
+ """It must not grow without limit in a long-lived process."""
230
+ from trodo.otel import prompt_trace
231
+
232
+ mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
233
+ p = mgr.get("refund-agent", cache_ttl_seconds=0)
234
+ for _ in range(prompt_trace._MAX_CARRIERS + 200):
235
+ p.compile(q="x")
236
+
237
+ assert len(prompt_trace._carrier_refs) <= prompt_trace._MAX_CARRIERS
238
+ assert len(prompt_trace._carrier_keepalive) <= prompt_trace._MAX_CARRIERS
239
+
240
+
241
+ def test_run_set_input_records_the_prompt_on_the_run(processor, http):
242
+ """The shape that still leaves auto-instrumented spans unlabelled: compile
243
+ up front, hand the messages to the RUN, let a framework make the call. The
244
+ run can at least say which prompt it ran."""
245
+ mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
246
+ p = mgr.get("refund-agent", cache_ttl_seconds=0)
247
+ compiled = p.compile(q="z")
248
+
249
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat") as run:
250
+ run.set_input(compiled.messages)
251
+
252
+ used = http.run_ingest[0]["run"]["attributes"]["trodo.prompts"]
253
+ assert [r["name"] for r in used] == ["refund-agent"]
254
+
255
+
256
+ def test_run_set_input_with_ordinary_input_records_nothing(processor, http):
257
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat") as run:
258
+ run.set_input({"query": "hello"})
259
+
260
+ attrs = http.run_ingest[0]["run"].get("attributes") or {}
261
+ assert "trodo.prompts" not in attrs
@@ -401,6 +401,46 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
401
401
  return True
402
402
 
403
403
 
404
+ def _instrument(module_name: str, *preferred: str) -> None:
405
+ """Import ``module_name`` and call ``instrument()`` on its Instrumentor.
406
+
407
+ Upstream renames the exported class often enough that pinning one exact
408
+ spelling is a silent-failure generator: the import raises, the caller's
409
+ ``except Exception`` treats it as "package not installed", and the user who
410
+ installed exactly what we told them to gets no spans and no error. That is
411
+ how ``LangChainInstrumentor`` (upstream ships ``LangchainInstrumentor``) and
412
+ ``GoogleGenerativeAIInstrumentor`` both went dark.
413
+
414
+ So: try the known spellings in order, then fall back to whatever single
415
+ ``*Instrumentor`` the module exports. Mirrors ``instrCtor`` in the Node SDK.
416
+ Raises ImportError if nothing usable is found, which the caller reports.
417
+ """
418
+ import importlib
419
+
420
+ mod = importlib.import_module(module_name)
421
+ cls = None
422
+ for name in preferred:
423
+ cls = getattr(mod, name, None)
424
+ if cls is not None:
425
+ break
426
+ if cls is None:
427
+ found = [
428
+ n for n in dir(mod)
429
+ if n.endswith("Instrumentor") and n != "BaseInstrumentor"
430
+ and isinstance(getattr(mod, n, None), type)
431
+ ]
432
+ if len(found) == 1:
433
+ cls = getattr(mod, found[0])
434
+ elif found:
435
+ raise ImportError(
436
+ f"{module_name} exports several Instrumentor classes {found}; "
437
+ f"none matched the expected names {list(preferred)}"
438
+ )
439
+ if cls is None:
440
+ raise ImportError(f"no Instrumentor class exported by {module_name}")
441
+ cls().instrument()
442
+
443
+
404
444
  _INSTRUMENTORS: List[tuple[str, Callable[[], Any]]] = []
405
445
 
406
446
 
@@ -416,84 +456,49 @@ def _register_instrumentors() -> None:
416
456
  return
417
457
 
418
458
  def _anthropic() -> Any:
419
- from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor # type: ignore
420
-
421
- AnthropicInstrumentor().instrument()
459
+ _instrument("opentelemetry.instrumentation.anthropic", "AnthropicInstrumentor")
422
460
 
423
461
  def _openai() -> Any:
424
- from opentelemetry.instrumentation.openai import OpenAIInstrumentor # type: ignore
425
-
426
- OpenAIInstrumentor().instrument()
462
+ _instrument("opentelemetry.instrumentation.openai", "OpenAIInstrumentor")
427
463
 
428
464
  def _openai_v2() -> Any:
429
- from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor # type: ignore
430
-
431
- OpenAIInstrumentor().instrument()
465
+ _instrument("opentelemetry.instrumentation.openai_v2", "OpenAIInstrumentor")
432
466
 
433
467
  def _langchain() -> Any:
434
- from opentelemetry.instrumentation.langchain import LangChainInstrumentor # type: ignore
435
-
436
- LangChainInstrumentor().instrument()
468
+ _instrument("opentelemetry.instrumentation.langchain", "LangchainInstrumentor", "LangChainInstrumentor")
437
469
 
438
470
  def _llama_index() -> Any:
439
- from opentelemetry.instrumentation.llama_index import LlamaIndexInstrumentor # type: ignore
440
-
441
- LlamaIndexInstrumentor().instrument()
471
+ _instrument("opentelemetry.instrumentation.llama_index", "LlamaIndexInstrumentor")
442
472
 
443
473
  def _google_generativeai() -> Any:
444
- import opentelemetry.instrumentation.google_generativeai as _m # type: ignore
445
-
446
- # The exported class name varies by version: GoogleGenerativeAiInstrumentor
447
- # (current — note the lowercase 'i'), GoogleGenerativeAIInstrumentor, or
448
- # GenAIInstrumentor. Importing the wrong casing silently disabled Gemini
449
- # auto-instrumentation. (This instrumentation patches the new @google/genai
450
- # SDK, i.e. the `google-genai` package, not legacy google-generativeai.)
451
- instr = (
452
- getattr(_m, "GoogleGenerativeAiInstrumentor", None)
453
- or getattr(_m, "GoogleGenerativeAIInstrumentor", None)
454
- or getattr(_m, "GenAIInstrumentor", None)
474
+ # Patches the new google-genai SDK, not legacy google-generativeai.
475
+ _instrument(
476
+ "opentelemetry.instrumentation.google_generativeai",
477
+ "GoogleGenerativeAiInstrumentor",
478
+ "GoogleGenerativeAIInstrumentor",
479
+ "GenAIInstrumentor",
455
480
  )
456
- if instr is None:
457
- raise ImportError(
458
- "no GoogleGenerativeAi/GenAI Instrumentor in "
459
- "opentelemetry.instrumentation.google_generativeai"
460
- )
461
- instr().instrument()
462
481
 
463
482
  def _vertexai() -> Any:
464
- from opentelemetry.instrumentation.vertexai import VertexAIInstrumentor # type: ignore
465
-
466
- VertexAIInstrumentor().instrument()
483
+ _instrument("opentelemetry.instrumentation.vertexai", "VertexAIInstrumentor")
467
484
 
468
485
  def _bedrock() -> Any:
469
- from opentelemetry.instrumentation.bedrock import BedrockInstrumentor # type: ignore
470
-
471
- BedrockInstrumentor().instrument()
486
+ _instrument("opentelemetry.instrumentation.bedrock", "BedrockInstrumentor")
472
487
 
473
488
  def _cohere() -> Any:
474
- from opentelemetry.instrumentation.cohere import CohereInstrumentor # type: ignore
475
-
476
- CohereInstrumentor().instrument()
489
+ _instrument("opentelemetry.instrumentation.cohere", "CohereInstrumentor")
477
490
 
478
491
  def _mistralai() -> Any:
479
- from opentelemetry.instrumentation.mistralai import MistralAiInstrumentor # type: ignore
480
-
481
- MistralAiInstrumentor().instrument()
492
+ _instrument("opentelemetry.instrumentation.mistralai", "MistralAiInstrumentor")
482
493
 
483
494
  def _haystack() -> Any:
484
- from opentelemetry.instrumentation.haystack import HaystackInstrumentor # type: ignore
485
-
486
- HaystackInstrumentor().instrument()
495
+ _instrument("opentelemetry.instrumentation.haystack", "HaystackInstrumentor")
487
496
 
488
497
  def _httpx() -> Any:
489
- from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor # type: ignore
490
-
491
- HTTPXClientInstrumentor().instrument()
498
+ _instrument("opentelemetry.instrumentation.httpx", "HTTPXClientInstrumentor")
492
499
 
493
500
  def _requests() -> Any:
494
- from opentelemetry.instrumentation.requests import RequestsInstrumentor # type: ignore
495
-
496
- RequestsInstrumentor().instrument()
501
+ _instrument("opentelemetry.instrumentation.requests", "RequestsInstrumentor")
497
502
 
498
503
  _INSTRUMENTORS = [
499
504
  ("anthropic", _anthropic),
@@ -14,6 +14,7 @@ Mirrors ``sdks/trodo-node-sdk/src/otel/promptTrace.ts``.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ from collections import OrderedDict
17
18
  from typing import Any, Dict, List, Optional
18
19
 
19
20
  from .context import get_active_context
@@ -27,6 +28,55 @@ def new_prompt_state() -> Dict[str, Any]:
27
28
  return {"current": None, "all": []}
28
29
 
29
30
 
31
+ # Compiled prompt (or its messages list) -> the version it came from.
32
+ #
33
+ # Mirrors the Node SDK. The run-scope mechanism below only fires when compile()
34
+ # runs inside a run, which leaves the ordinary "fetch and compile up front, open
35
+ # the run around the model call" shape with no link at all. Tagging the compiled
36
+ # value means a span handed those messages as ``input`` recovers the version
37
+ # wherever compile() happened.
38
+ #
39
+ # Keyed by id() with a weak-ish discipline: lists are unhashable and cannot go
40
+ # in a WeakValueDictionary, so entries are bounded and evicted FIFO rather than
41
+ # held forever. The link is best-effort — losing an old entry costs one span's
42
+ # label, never correctness.
43
+ _MAX_CARRIERS = 4096
44
+ _carrier_refs: "OrderedDict[int, PromptRef]" = OrderedDict()
45
+ _carrier_keepalive: "OrderedDict[int, Any]" = OrderedDict()
46
+
47
+
48
+ def tag_carrier_with_prompt(carrier: Any, ref: PromptRef) -> None:
49
+ """Attach a prompt identity to a value the caller will pass to a span."""
50
+ if carrier is None or isinstance(carrier, (str, bytes, int, float, bool)):
51
+ return
52
+ key = id(carrier)
53
+ _carrier_refs[key] = ref
54
+ # Hold a reference so the id cannot be recycled by another object while the
55
+ # mapping still names it — that would mislabel an unrelated span.
56
+ _carrier_keepalive[key] = carrier
57
+ _carrier_refs.move_to_end(key)
58
+ _carrier_keepalive.move_to_end(key)
59
+ while len(_carrier_refs) > _MAX_CARRIERS:
60
+ old, _ = _carrier_refs.popitem(last=False)
61
+ _carrier_keepalive.pop(old, None)
62
+
63
+
64
+ def prompt_ref_from_carrier(carrier: Any) -> Optional[PromptRef]:
65
+ """Recover the prompt identity from a span's input, if it carries one."""
66
+ if carrier is None or isinstance(carrier, (str, bytes, int, float, bool)):
67
+ return None
68
+ return _carrier_refs.get(id(carrier))
69
+
70
+
71
+ def remember_prompt(state: Any, ref: PromptRef) -> None:
72
+ """Add a ref to the run's deduped set if it isn't already there."""
73
+ if not isinstance(state, dict) or not ref or not ref.get("name"):
74
+ return
75
+ key = _ref_key(ref)
76
+ if not any(_ref_key(r) == key for r in state.get("all", [])):
77
+ state.setdefault("all", []).append(ref)
78
+
79
+
30
80
  def _ref_key(ref: PromptRef) -> str:
31
81
  return ref.get("version_hash") or ref.get("name") or ""
32
82
 
@@ -66,12 +116,24 @@ def prompt_attributes(ref: Optional[PromptRef]) -> Dict[str, str]:
66
116
 
67
117
 
68
118
  def merge_prompt_attrs(
69
- active: Any, attrs: Optional[Dict[str, Any]]
119
+ active: Any, attrs: Optional[Dict[str, Any]], from_input: Optional[PromptRef] = None
70
120
  ) -> Optional[Dict[str, Any]]:
71
- """Merge the active run's most-recently-compiled prompt into span attributes."""
121
+ """Merge the prompt this span used into its attributes.
122
+
123
+ The span's own INPUT wins over the run's most-recently-compiled prompt: it
124
+ is more specific (in a run using two prompts, a span holding prompt A's
125
+ messages must report A even if B was compiled later), and it is the only
126
+ signal available when compile() ran outside the run.
127
+
128
+ A prompt found this way is also folded into the run's deduped set so the
129
+ run-level ``trodo.prompts`` list stays complete either way.
130
+ """
72
131
  merged: Dict[str, Any] = dict(attrs or {})
73
132
  state = getattr(active, "prompt_state", None) if active is not None else None
74
- if isinstance(state, dict):
133
+ if from_input is not None:
134
+ remember_prompt(state, from_input)
135
+ merged.update(prompt_attributes(from_input))
136
+ elif isinstance(state, dict):
75
137
  merged.update(prompt_attributes(state.get("current")))
76
138
  return merged or None
77
139
 
@@ -32,7 +32,12 @@ from datetime import datetime, timezone
32
32
  from typing import Any, Callable, Dict, List, Optional, Union
33
33
 
34
34
  from .context import ActiveSpanContext, get_active_context, run_with_context
35
- from .prompt_trace import merge_prompt_attrs, new_prompt_state
35
+ from .prompt_trace import (
36
+ merge_prompt_attrs,
37
+ new_prompt_state,
38
+ prompt_ref_from_carrier,
39
+ remember_prompt,
40
+ )
36
41
  from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
37
42
  from .transport import get_transport_mode, get_otel_tracer, get_otel_helpers
38
43
 
@@ -281,13 +286,21 @@ def _tag_error_with_run(exc: object, run_id: Optional[str]) -> None:
281
286
  class RunHandle:
282
287
  """Handle returned by wrap_agent for setting input/output and getting run_id."""
283
288
 
284
- def __init__(self, run_id: str, agent_name: str, distinct_id: str) -> None:
289
+ def __init__(
290
+ self,
291
+ run_id: str,
292
+ agent_name: str,
293
+ distinct_id: str,
294
+ prompt_state: Optional[Dict[str, Any]] = None,
295
+ ) -> None:
285
296
  self.run_id = run_id
286
297
  self.agent_name = agent_name
287
298
  # Always populated — wrap_agent mints anon if caller didn't pass one
288
299
  # so downstream ``trodo.feedback(distinct_id=...)`` always has a target.
289
300
  self.distinct_id = distinct_id
290
301
  self.input: Optional[Union[str, Dict[str, Any]]] = None
302
+ #: The run's prompt accumulator, so set_input can register a prompt.
303
+ self._prompt_state = prompt_state
291
304
  self.output: Optional[Union[str, Dict[str, Any]]] = None
292
305
  self.metadata: Dict[str, Any] = {}
293
306
  # Manually-recorded run-level error (via set_error_summary). When set
@@ -298,7 +311,16 @@ class RunHandle:
298
311
  def set_input(self, value: Any) -> None:
299
312
  """Set the run input. Prefer a chat-message list
300
313
  ``[{"role": "user", "content": ...}, ...]`` — the backend embeds the
301
- user messages as the run's semantic input."""
314
+ user messages as the run's semantic input.
315
+
316
+ Handing this the messages from a managed prompt also records that
317
+ prompt on the run: ``run.set_input(compiled.messages)`` is a common
318
+ shape, and the run demonstrably used that prompt, so the run-level
319
+ ``trodo.prompts`` list should say so even when compile() ran outside
320
+ the run."""
321
+ ref = prompt_ref_from_carrier(value)
322
+ if ref is not None:
323
+ remember_prompt(self._prompt_state, ref)
302
324
  self.input = _prepare_value(value)
303
325
 
304
326
  def set_output(self, value: Any) -> None:
@@ -330,6 +352,10 @@ class SpanHandle:
330
352
  self.span_id = span_id
331
353
  self.name = name
332
354
  self.input: Optional[Union[str, Dict[str, Any]]] = None
355
+ #: Prompt this span used, recovered from the value passed as input.
356
+ #: Captured from the ORIGINAL object: _prepare_value JSON-serialises
357
+ #: anything that isn't a dict/list, and identity cannot survive that.
358
+ self.prompt_ref: Optional[Dict[str, Any]] = None
333
359
  self.output: Optional[Union[str, Dict[str, Any]]] = None
334
360
  self.attributes: Dict[str, Any] = {}
335
361
  self.model: Optional[str] = None
@@ -393,6 +419,7 @@ class SpanHandle:
393
419
  ``context`` for RAG docs, any order, multiple per role) — each role is
394
420
  embedded separately and powers the AI-score detectors. Anything else
395
421
  is stored as one opaque input."""
422
+ self.prompt_ref = prompt_ref_from_carrier(value) or self.prompt_ref
396
423
  self.input = _prepare_value(value)
397
424
 
398
425
  def set_output(self, value: Any) -> None:
@@ -594,7 +621,6 @@ class wrap_agent:
594
621
  self._started_iso = _now_iso()
595
622
  self._started_ms = time.time() * 1000.0
596
623
 
597
- self.handle = RunHandle(run_id, self._agent_name, self._distinct_id)
598
624
  ctx = ActiveSpanContext(
599
625
  run_id=run_id,
600
626
  span_id=root_span_id,
@@ -603,6 +629,9 @@ class wrap_agent:
603
629
  processor=self._processor,
604
630
  prompt_state=new_prompt_state(),
605
631
  )
632
+ self.handle = RunHandle(
633
+ run_id, self._agent_name, self._distinct_id, ctx.prompt_state
634
+ )
606
635
  self._ctx = ctx
607
636
  self._ctx_mgr = run_with_context(ctx)
608
637
  self._ctx_mgr.__enter__()
@@ -708,7 +737,12 @@ class wrap_agent:
708
737
  otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
709
738
 
710
739
  self._otel_span = otel_span
711
- self.handle = RunHandle(run_id, self._agent_name, self._distinct_id)
740
+ # OTLP mode builds no Trodo run context (documented limitation), so
741
+ # the handle gets a standalone accumulator — enough for set_input() to
742
+ # register a prompt even here.
743
+ self.handle = RunHandle(
744
+ run_id, self._agent_name, self._distinct_id, new_prompt_state()
745
+ )
712
746
  return self.handle
713
747
 
714
748
  def _exit_otel(self, exc_type, exc, tb) -> None:
@@ -770,6 +804,7 @@ class join_run:
770
804
  self._parent_span_id = parent_span_id
771
805
  self._name = name
772
806
  self._kind = kind
807
+ self._prompt_ref = prompt_ref_from_carrier(input)
773
808
  self._input = _prepare_value(input) if input is not None else None
774
809
  self._attributes = attributes
775
810
  self._ctx_mgr: Optional[run_with_context] = None
@@ -786,6 +821,8 @@ class join_run:
786
821
  self.handle = SpanHandle(self._span_id, self._name)
787
822
  if self._input is not None:
788
823
  self.handle.input = self._input
824
+ if self._prompt_ref is not None:
825
+ self.handle.prompt_ref = self._prompt_ref
789
826
  if self._attributes:
790
827
  self.handle.attributes.update(self._attributes)
791
828
 
@@ -838,7 +875,9 @@ class join_run:
838
875
  cost_details=self.handle.cost_details,
839
876
  temperature=self.handle.temperature,
840
877
  tool_name=self.handle.tool_name,
841
- attributes=merge_prompt_attrs(self._ctx, self.handle.attributes),
878
+ attributes=merge_prompt_attrs(
879
+ self._ctx, self.handle.attributes, self.handle.prompt_ref
880
+ ),
842
881
  )
843
882
  try:
844
883
  self._processor.append_spans(self._run_id, [trodo_span])
@@ -865,6 +904,7 @@ class span:
865
904
  ) -> None:
866
905
  self._name = name
867
906
  self._kind = kind
907
+ self._prompt_ref = prompt_ref_from_carrier(input)
868
908
  self._input = _prepare_value(input) if input is not None else None
869
909
  self._attributes = attributes
870
910
  self._ctx_mgr: Optional[run_with_context] = None
@@ -889,6 +929,8 @@ class span:
889
929
  self.handle = SpanHandle(self._span_id, self._name)
890
930
  if self._input is not None:
891
931
  self.handle.input = self._input
932
+ if self._prompt_ref is not None:
933
+ self.handle.prompt_ref = self._prompt_ref
892
934
  if self._attributes:
893
935
  self.handle.attributes.update(self._attributes)
894
936
  if self._active is None:
@@ -945,7 +987,9 @@ class span:
945
987
  cost_details=self.handle.cost_details,
946
988
  temperature=self.handle.temperature,
947
989
  tool_name=self.handle.tool_name,
948
- attributes=merge_prompt_attrs(self._active, self.handle.attributes),
990
+ attributes=merge_prompt_attrs(
991
+ self._active, self.handle.attributes, self.handle.prompt_ref
992
+ ),
949
993
  )
950
994
  processor: TrodoSpanProcessor = self._active.processor # type: ignore[assignment]
951
995
  processor.enqueue_span(trodo_span)
@@ -971,6 +1015,8 @@ class span:
971
1015
  self.handle = SpanHandle(self._span_id, self._name)
972
1016
  if self._input is not None:
973
1017
  self.handle.input = self._input
1018
+ if self._prompt_ref is not None:
1019
+ self.handle.prompt_ref = self._prompt_ref
974
1020
  if self._attributes:
975
1021
  self.handle.attributes.update(self._attributes)
976
1022
  return self.handle
@@ -89,21 +89,30 @@ class ManagedPrompt:
89
89
  values = dict(variables or {})
90
90
  values.update(kwargs)
91
91
  compiled = compile_prompt(self, values)
92
- # Prompt traceability: record the exact version compiled so any span
93
- # emitted in the surrounding wrap_agent/span scope carries it. No-op
94
- # outside a run context. Never records a fallback (no real hash).
92
+ # Prompt traceability. Never records a fallback it has no real hash,
93
+ # so a link to it would name a version that does not exist.
95
94
  if not self.is_fallback:
96
95
  try:
97
- from ..otel.prompt_trace import record_compiled_prompt
98
-
99
- record_compiled_prompt(
100
- {
101
- "name": self.name,
102
- "version_hash": self.version_hash,
103
- "content_hash": self.content_hash,
104
- "label": self.trace_label,
105
- }
96
+ from ..otel.prompt_trace import (
97
+ record_compiled_prompt,
98
+ tag_carrier_with_prompt,
106
99
  )
100
+
101
+ ref = {
102
+ "name": self.name,
103
+ "version_hash": self.version_hash,
104
+ "content_hash": self.content_hash,
105
+ "label": self.trace_label,
106
+ }
107
+ # (1) the surrounding run scope, if any — the only route that
108
+ # reaches auto-instrumented spans, whose input we never see.
109
+ record_compiled_prompt(ref)
110
+ # (2) the compiled value itself, so a span handed these messages
111
+ # as ``input`` recovers the version even when compile() ran
112
+ # outside the run. Both the wrapper and the list are tagged,
113
+ # since callers pass one or the other.
114
+ tag_carrier_with_prompt(compiled, ref)
115
+ tag_carrier_with_prompt(getattr(compiled, "messages", None), ref)
107
116
  except Exception: # noqa: BLE001
108
117
  pass # traceability must never break compile
109
118
  return compiled
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.21.0
3
+ Version: 2.22.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
@@ -8,6 +8,7 @@ tests/test_end_run.py
8
8
  tests/test_error_enrichment.py
9
9
  tests/test_experiments.py
10
10
  tests/test_get_prompt_version_types.py
11
+ tests/test_instrumentor_resolution.py
11
12
  tests/test_llm_usage_cost.py
12
13
  tests/test_multi_agent_shape.py
13
14
  tests/test_processor_methods.py
@@ -1,107 +0,0 @@
1
- """Prompt -> span traceability (mirrors the Node promptTrace.test.ts).
2
-
3
- Compiling a managed prompt inside a wrap_agent/span scope must stamp the exact
4
- version (by immutable hash) onto the emitted span, and aggregate every prompt
5
- used onto the run. Outside a run, it's a silent no-op.
6
- """
7
- from __future__ import annotations
8
-
9
- from trodo.managers.prompt_manager import PromptManager
10
- from trodo.otel.wrap_agent import span, wrap_agent
11
-
12
- PROMPT_WIRE = {
13
- "name": "refund-agent",
14
- "version": 3,
15
- "version_hash": "a3f9c2e8d1b04756",
16
- "content_hash": "ccccontent1234",
17
- "parent_hash": "pppparent5678",
18
- "labels": ["production"],
19
- "tags": [],
20
- "messages": [{"role": "system", "content": [{"type": "text", "text": "Help {{q}}"}]}],
21
- "model": {"provider": "openai", "model": "gpt-4o"},
22
- "tools": [],
23
- "response_format": None,
24
- "variables": [{"name": "q", "type": "string"}],
25
- "updated_at": "2026-07-01T00:00:00Z",
26
- }
27
-
28
-
29
- class PromptHttp:
30
- def __init__(self, prompt=None, fail=False):
31
- self._prompt = prompt
32
- self.fail = fail
33
-
34
- def get_prompt(self, name, label=None, version=None):
35
- if self.fail:
36
- raise RuntimeError("unreachable")
37
- return {"prompt": dict(self._prompt)}
38
-
39
-
40
- def test_stamps_version_hash_on_span_and_aggregates_on_run(processor, http):
41
- mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
42
- with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
43
- p = mgr.get("refund-agent", cache_ttl_seconds=0)
44
- with span("llm", kind="llm"):
45
- p.compile(q="my order")
46
-
47
- ingest = http.run_ingest[0]
48
- spans = ingest["spans"]
49
- llm = next(s for s in spans if s["name"] == "llm")
50
- attrs = llm["attributes"]
51
- assert attrs["trodo.prompt.name"] == "refund-agent"
52
- assert attrs["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
53
- assert attrs["trodo.prompt.label"] == "production" # default selector
54
- assert attrs["trodo.prompt.content_hash"] == "ccccontent1234"
55
-
56
- used = ingest["run"]["attributes"]["trodo.prompts"]
57
- assert len(used) == 1
58
- assert used[0]["version_hash"] == "a3f9c2e8d1b04756"
59
-
60
-
61
- def test_records_label_and_omits_it_for_version_pin(processor, http):
62
- mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
63
- with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
64
- staged = mgr.get("refund-agent", label="staging", cache_ttl_seconds=0)
65
- with span("s1"):
66
- staged.compile(q="x")
67
- pinned = mgr.get("refund-agent", version="a3f9c2", cache_ttl_seconds=0)
68
- with span("s2"):
69
- pinned.compile(q="y")
70
-
71
- spans = http.run_ingest[0]["spans"]
72
- s1 = next(s for s in spans if s["name"] == "s1")["attributes"]
73
- s2 = next(s for s in spans if s["name"] == "s2")["attributes"]
74
- assert s1["trodo.prompt.label"] == "staging"
75
- assert "trodo.prompt.label" not in s2 # pinned by hash -> no label
76
- assert s2["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
77
-
78
-
79
- def test_no_op_outside_a_run(processor, http):
80
- mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
81
- p = mgr.get("refund-agent", cache_ttl_seconds=0)
82
- # Must not raise despite there being no active run context.
83
- p.compile(q="z")
84
- assert len(http.run_ingest) == 0
85
-
86
-
87
- def test_fallback_prompt_is_not_traced(processor, http):
88
- mgr = PromptManager(PromptHttp(fail=True))
89
- with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
90
- p = mgr.get(
91
- "refund-agent",
92
- cache_ttl_seconds=0,
93
- max_retries=0,
94
- fallback={
95
- "messages": [{"role": "user", "content": [{"type": "text", "text": "hi {{q}}"}]}],
96
- "variables": [{"name": "q"}],
97
- },
98
- )
99
- assert p.is_fallback is True
100
- with span("llm"):
101
- p.compile(q="x")
102
-
103
- spans = http.run_ingest[0]["spans"]
104
- attrs = next(s for s in spans if s["name"] == "llm").get("attributes") or {}
105
- assert "trodo.prompt.name" not in attrs
106
- # to_dict() drops None, so an untraced run has no 'attributes' key at all.
107
- assert "attributes" not in http.run_ingest[0]["run"]
File without changes
File without changes