trodo-python 2.13.0__tar.gz → 2.15.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 (54) hide show
  1. {trodo_python-2.13.0 → trodo_python-2.15.0}/PKG-INFO +1 -1
  2. {trodo_python-2.13.0 → trodo_python-2.15.0}/pyproject.toml +1 -1
  3. trodo_python-2.15.0/tests/test_prompt_trace.py +107 -0
  4. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/__init__.py +1 -1
  5. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/managers/prompt_manager.py +22 -9
  6. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/auto_instrument.py +9 -0
  7. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/context.py +4 -0
  8. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/processor.py +3 -0
  9. trodo_python-2.15.0/trodo/otel/prompt_trace.py +94 -0
  10. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/wrap_agent.py +17 -2
  11. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/prompts/types.py +30 -1
  12. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo_python.egg-info/PKG-INFO +1 -1
  13. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo_python.egg-info/SOURCES.txt +2 -0
  14. {trodo_python-2.13.0 → trodo_python-2.15.0}/README.md +0 -0
  15. {trodo_python-2.13.0 → trodo_python-2.15.0}/setup.cfg +0 -0
  16. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_anon_distinct_id.py +0 -0
  17. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_auto_instrument_fixes.py +0 -0
  18. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_cross_process_session.py +0 -0
  19. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_end_run.py +0 -0
  20. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_error_enrichment.py +0 -0
  21. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_llm_usage_cost.py +0 -0
  22. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_processor_methods.py +0 -0
  23. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_prompts.py +0 -0
  24. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_register_otel.py +0 -0
  25. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_start_run.py +0 -0
  26. {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_wrap_agent_unchanged.py +0 -0
  27. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/api/__init__.py +0 -0
  28. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/api/async_client.py +0 -0
  29. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/api/endpoints.py +0 -0
  30. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/api/http_client.py +0 -0
  31. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/auto/__init__.py +0 -0
  32. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/auto/auto_event_manager.py +0 -0
  33. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/client.py +0 -0
  34. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/managers/__init__.py +0 -0
  35. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/managers/group_manager.py +0 -0
  36. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/managers/people_manager.py +0 -0
  37. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/__init__.py +0 -0
  38. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/helpers.py +0 -0
  39. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/register.py +0 -0
  40. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/transport.py +0 -0
  41. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/prompts/__init__.py +0 -0
  42. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/prompts/compile.py +0 -0
  43. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/prompts/template.py +0 -0
  44. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/queue/__init__.py +0 -0
  45. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/queue/batch_flusher.py +0 -0
  46. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/queue/event_queue.py +0 -0
  47. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/session/__init__.py +0 -0
  48. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/session/server_session.py +0 -0
  49. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/session/session_manager.py +0 -0
  50. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/types.py +0 -0
  51. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/user_context.py +0 -0
  52. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  53. {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo_python.egg-info/requires.txt +0 -0
  54. {trodo_python-2.13.0 → trodo_python-2.15.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.13.0
3
+ Version: 2.15.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.13.0"
7
+ version = "2.15.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,107 @@
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"]
@@ -41,7 +41,7 @@ Downstream microservice (join the caller's run instead of making a new one):
41
41
 
42
42
  from __future__ import annotations
43
43
 
44
- __version__ = "2.13.0"
44
+ __version__ = "2.15.0"
45
45
 
46
46
  from typing import Any, Callable, Dict, List, Optional, Union
47
47
 
@@ -16,7 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import threading
18
18
  import time
19
- from typing import Any, Callable, Dict, List, Optional, Set
19
+ from typing import Any, Callable, Dict, List, Optional, Set, Union
20
20
 
21
21
  from ..prompts.compile import CompileError, compile_prompt
22
22
  from ..prompts.template import TemplateError, render
@@ -38,7 +38,7 @@ __all__ = [
38
38
  DEFAULT_TTL_SECONDS = 60.0
39
39
 
40
40
 
41
- def _cache_key(name: str, version: Optional[int], label: Optional[str]) -> str:
41
+ def _cache_key(name: str, version: Optional[Union[int, str]], label: Optional[str]) -> str:
42
42
  # Resolution happens server-side on every fetch; the client only caches
43
43
  # under whatever selector was asked for. So a label flip propagates within
44
44
  # one TTL without the client knowing anything about labels.
@@ -67,12 +67,19 @@ def _to_variables(raw: Any) -> List[PromptVariable]:
67
67
  return out
68
68
 
69
69
 
70
- def _to_prompt(raw: Dict[str, Any], is_fallback: bool = False) -> ManagedPrompt:
70
+ def _to_prompt(
71
+ raw: Dict[str, Any],
72
+ is_fallback: bool = False,
73
+ trace_label: Optional[str] = None,
74
+ ) -> ManagedPrompt:
71
75
  return ManagedPrompt(
72
76
  name=str(raw.get("name") or ""),
73
77
  description=raw.get("description"),
74
78
  tags=list(raw.get("tags") or []),
75
79
  version=int(raw.get("version") or 0),
80
+ version_hash=raw.get("version_hash"),
81
+ content_hash=raw.get("content_hash"),
82
+ parent_hash=raw.get("parent_hash"),
76
83
  labels=list(raw.get("labels") or []),
77
84
  messages=list(raw.get("messages") or []),
78
85
  model=dict(raw.get("model") or {}),
@@ -81,6 +88,7 @@ def _to_prompt(raw: Dict[str, Any], is_fallback: bool = False) -> ManagedPrompt:
81
88
  variables=_to_variables(raw.get("variables")),
82
89
  updated_at=raw.get("updated_at") or raw.get("updatedAt"),
83
90
  is_fallback=is_fallback,
91
+ trace_label=trace_label,
84
92
  )
85
93
 
86
94
 
@@ -172,7 +180,7 @@ class PromptManager:
172
180
  self,
173
181
  name: str,
174
182
  label: Optional[str] = None,
175
- version: Optional[int] = None,
183
+ version: Optional[Union[int, str]] = None,
176
184
  cache_ttl_seconds: Optional[float] = None,
177
185
  fallback: Optional[Dict[str, Any]] = None,
178
186
  max_retries: int = 2,
@@ -181,7 +189,8 @@ class PromptManager:
181
189
 
182
190
  Without arguments you get the version labelled ``production``; pass
183
191
  ``label`` to follow a different deploy label, or ``version`` to pin
184
- exactly.
192
+ exactly. ``version`` may be the integer ``version_no`` or a
193
+ ``version_hash`` (full, or an unambiguous short prefix like ``a3f9c2``).
185
194
 
186
195
  Availability ladder — fresh cache -> stale cache -> ``fallback`` ->
187
196
  raise. A prompt fetch is on your hot path, so a Trodo outage degrades
@@ -200,6 +209,10 @@ class PromptManager:
200
209
 
201
210
  key = _cache_key(name, version, label)
202
211
  ttl = DEFAULT_TTL_SECONDS if cache_ttl_seconds is None else float(cache_ttl_seconds)
212
+ # The deploy label this fetch followed, for prompt traceability. Explicit
213
+ # label wins; with neither label nor version pinned the SDK follows
214
+ # ``production``; a version pin has no label (the hash is the identity).
215
+ trace_label = label if label else ("production" if version is None else None)
203
216
 
204
217
  def fetcher() -> Dict[str, Any]:
205
218
  res = _with_retry(
@@ -219,24 +232,24 @@ class PromptManager:
219
232
  if ttl > 0:
220
233
  fresh = self._cache.get_fresh(key)
221
234
  if fresh is not None:
222
- return _to_prompt(fresh)
235
+ return _to_prompt(fresh, trace_label=trace_label)
223
236
 
224
237
  stale = self._cache.get_stale(key)
225
238
  if stale is not None:
226
239
  # Serve immediately, refresh behind the caller's back. A slow or
227
240
  # dead API costs latency on nobody's request.
228
241
  self._cache.revalidate(key, fetcher, ttl)
229
- return _to_prompt(stale)
242
+ return _to_prompt(stale, trace_label=trace_label)
230
243
 
231
244
  try:
232
245
  raw = fetcher()
233
246
  if ttl > 0:
234
247
  self._cache.set(key, raw, ttl)
235
- return _to_prompt(raw)
248
+ return _to_prompt(raw, trace_label=trace_label)
236
249
  except Exception:
237
250
  stale = self._cache.get_stale(key)
238
251
  if stale is not None:
239
- return _to_prompt(stale)
252
+ return _to_prompt(stale, trace_label=trace_label)
240
253
  if fallback:
241
254
  return _to_prompt(
242
255
  {
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
14
14
  from typing import Any, Callable, Dict, Iterable, List, Optional
15
15
 
16
16
  from .context import get_active_context
17
+ from .prompt_trace import prompt_attributes
17
18
  from .processor import TrodoSpan, TrodoSpanProcessor
18
19
 
19
20
  # Always-on, one-shot warnings. Silent skips (missing opentelemetry-sdk) are the
@@ -376,6 +377,14 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
376
377
  set_attr(_ATTR_TRODO_RUN_ID, ctx.run_id)
377
378
  if ctx.span_id:
378
379
  set_attr(_ATTR_TRODO_PARENT_SPAN_ID, ctx.span_id)
380
+ # Prompt traceability: stamp the exact managed-prompt version
381
+ # compiled just before this (auto-instrumented) provider call.
382
+ # Pinned by immutable version_hash, so a later label move never
383
+ # rewrites what a past span ran.
384
+ state = getattr(ctx, "prompt_state", None)
385
+ current = state.get("current") if isinstance(state, dict) else None
386
+ for k, v in prompt_attributes(current).items():
387
+ set_attr(k, v)
379
388
  except Exception:
380
389
  pass # never break user code
381
390
 
@@ -16,6 +16,10 @@ class ActiveSpanContext:
16
16
  parent_span_id: Optional[str]
17
17
  team_site_id: str
18
18
  processor: object # TrodoSpanProcessor — avoid circular import
19
+ # Per-run prompt-traceability accumulator ({"current", "all"}), shared by
20
+ # reference across the run's whole span tree. Records which managed prompt
21
+ # version each span used. See otel/prompt_trace.py.
22
+ prompt_state: Optional[dict] = None
19
23
 
20
24
 
21
25
  _active: contextvars.ContextVar[Optional[ActiveSpanContext]] = contextvars.ContextVar(
@@ -33,6 +33,9 @@ class TrodoRun:
33
33
  # free-text error_summary).
34
34
  error_type: Optional[str] = None
35
35
  metadata: Optional[Dict[str, Any]] = None
36
+ # Free-form run-level attributes (e.g. {"trodo.prompts": [...]} — the set of
37
+ # managed-prompt versions used across the run, for prompt traceability).
38
+ attributes: Optional[Dict[str, Any]] = None
36
39
  # Aggregates summed from child spans at finalisation.
37
40
  total_tokens_in: Optional[int] = None
38
41
  total_tokens_out: Optional[int] = None
@@ -0,0 +1,94 @@
1
+ """Prompt -> span traceability.
2
+
3
+ When a managed prompt is compiled inside a ``wrap_agent`` / ``span`` scope, we
4
+ record which exact version was used so every span emitted in that scope carries
5
+ it. The identity is the immutable ``version_hash`` — so a span always shows the
6
+ exact version that ran, even if a deploy label (e.g. ``production``) is later
7
+ moved to a different version.
8
+
9
+ The link is best-effort and never raises: if there is no active run context
10
+ (the prompt was compiled outside ``wrap_agent``), recording is a silent no-op.
11
+
12
+ Mirrors ``sdks/trodo-node-sdk/src/otel/promptTrace.ts``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from .context import get_active_context
20
+
21
+ # A PromptRef is a plain dict: {name, version_hash?, content_hash?, label?}.
22
+ PromptRef = Dict[str, Any]
23
+
24
+
25
+ def new_prompt_state() -> Dict[str, Any]:
26
+ """Per-run accumulator, shared by reference across the run's span tree."""
27
+ return {"current": None, "all": []}
28
+
29
+
30
+ def _ref_key(ref: PromptRef) -> str:
31
+ return ref.get("version_hash") or ref.get("name") or ""
32
+
33
+
34
+ def record_compiled_prompt(ref: PromptRef) -> None:
35
+ """Record that a prompt was just compiled.
36
+
37
+ Updates the active run's prompt state (most-recent + the deduped set) and,
38
+ in OTLP mode where a real OpenTelemetry span is current, stamps that span.
39
+ """
40
+ if not ref or not ref.get("name"):
41
+ return
42
+ active = get_active_context()
43
+ state = getattr(active, "prompt_state", None) if active is not None else None
44
+ if isinstance(state, dict):
45
+ state["current"] = ref
46
+ key = _ref_key(ref)
47
+ used: List[PromptRef] = state["all"]
48
+ if not any(_ref_key(r) == key for r in used):
49
+ used.append(ref)
50
+ _stamp_active_otel_span(ref)
51
+
52
+
53
+ def prompt_attributes(ref: Optional[PromptRef]) -> Dict[str, str]:
54
+ """Flatten a PromptRef to primitive span attributes (None values omitted)."""
55
+ out: Dict[str, str] = {}
56
+ if not ref or not ref.get("name"):
57
+ return out
58
+ out["trodo.prompt.name"] = ref["name"]
59
+ if ref.get("version_hash"):
60
+ out["trodo.prompt.version_hash"] = ref["version_hash"]
61
+ if ref.get("label"):
62
+ out["trodo.prompt.label"] = ref["label"]
63
+ if ref.get("content_hash"):
64
+ out["trodo.prompt.content_hash"] = ref["content_hash"]
65
+ return out
66
+
67
+
68
+ def merge_prompt_attrs(
69
+ active: Any, attrs: Optional[Dict[str, Any]]
70
+ ) -> Optional[Dict[str, Any]]:
71
+ """Merge the active run's most-recently-compiled prompt into span attributes."""
72
+ merged: Dict[str, Any] = dict(attrs or {})
73
+ state = getattr(active, "prompt_state", None) if active is not None else None
74
+ if isinstance(state, dict):
75
+ merged.update(prompt_attributes(state.get("current")))
76
+ return merged or None
77
+
78
+
79
+ def _stamp_active_otel_span(ref: PromptRef) -> None:
80
+ """OTLP mode only: stamp the current real OTel span, if one exists.
81
+
82
+ Wrapped in try/except — ``opentelemetry`` is an optional dependency present
83
+ only when the user runs in OTLP mode.
84
+ """
85
+ try:
86
+ from opentelemetry import trace # type: ignore
87
+
88
+ span = trace.get_current_span()
89
+ if span is None:
90
+ return
91
+ for k, v in prompt_attributes(ref).items():
92
+ span.set_attribute(k, v)
93
+ except Exception: # noqa: BLE001
94
+ pass # not in OTLP mode / opentelemetry not installed — no-op
@@ -32,6 +32,7 @@ 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
36
  from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
36
37
  from .transport import get_transport_mode, get_otel_tracer, get_otel_helpers
37
38
 
@@ -515,6 +516,7 @@ class wrap_agent:
515
516
  self._parent_run_id = parent_run_id
516
517
  self._metadata = metadata
517
518
  self._ctx_mgr: Optional[run_with_context] = None
519
+ self._ctx: Optional[ActiveSpanContext] = None
518
520
  self._started_ms: float = 0.0
519
521
  self._started_iso: str = ""
520
522
  self.handle: Optional[RunHandle] = None
@@ -551,7 +553,9 @@ class wrap_agent:
551
553
  parent_span_id=None,
552
554
  team_site_id=self._team_site_id,
553
555
  processor=self._processor,
556
+ prompt_state=new_prompt_state(),
554
557
  )
558
+ self._ctx = ctx
555
559
  self._ctx_mgr = run_with_context(ctx)
556
560
  self._ctx_mgr.__enter__()
557
561
  return self.handle
@@ -596,6 +600,13 @@ class wrap_agent:
596
600
  error_summary=error_summary,
597
601
  error_type=error_type,
598
602
  metadata={**(self._metadata or {}), **self.handle.metadata} or None,
603
+ attributes=(
604
+ {"trodo.prompts": self._ctx.prompt_state["all"]}
605
+ if self._ctx is not None
606
+ and self._ctx.prompt_state
607
+ and self._ctx.prompt_state["all"]
608
+ else None
609
+ ),
599
610
  total_tokens_in=agg["total_tokens_in"],
600
611
  total_tokens_out=agg["total_tokens_out"],
601
612
  total_cost=agg["total_cost"],
@@ -709,6 +720,7 @@ class join_run:
709
720
  self._input = _prepare_value(input) if input is not None else None
710
721
  self._attributes = attributes
711
722
  self._ctx_mgr: Optional[run_with_context] = None
723
+ self._ctx: Optional[ActiveSpanContext] = None
712
724
  self._started_ms: float = 0.0
713
725
  self._started_iso: str = ""
714
726
  self._span_id: str = ""
@@ -731,7 +743,9 @@ class join_run:
731
743
  parent_span_id=self._parent_span_id,
732
744
  team_site_id=self._team_site_id,
733
745
  processor=self._processor,
746
+ prompt_state=new_prompt_state(),
734
747
  )
748
+ self._ctx = ctx
735
749
  self._ctx_mgr = run_with_context(ctx)
736
750
  self._ctx_mgr.__enter__()
737
751
  return self.handle
@@ -771,7 +785,7 @@ class join_run:
771
785
  cost_details=self.handle.cost_details,
772
786
  temperature=self.handle.temperature,
773
787
  tool_name=self.handle.tool_name,
774
- attributes=self.handle.attributes or None,
788
+ attributes=merge_prompt_attrs(self._ctx, self.handle.attributes),
775
789
  )
776
790
  try:
777
791
  self._processor.append_spans(self._run_id, [trodo_span])
@@ -833,6 +847,7 @@ class span:
833
847
  parent_span_id=self._active.span_id,
834
848
  team_site_id=self._active.team_site_id,
835
849
  processor=self._active.processor,
850
+ prompt_state=self._active.prompt_state,
836
851
  )
837
852
  self._ctx_mgr = run_with_context(child)
838
853
  self._ctx_mgr.__enter__()
@@ -877,7 +892,7 @@ class span:
877
892
  cost_details=self.handle.cost_details,
878
893
  temperature=self.handle.temperature,
879
894
  tool_name=self.handle.tool_name,
880
- attributes=self.handle.attributes or None,
895
+ attributes=merge_prompt_attrs(self._active, self.handle.attributes),
881
896
  )
882
897
  processor: TrodoSpanProcessor = self._active.processor # type: ignore[assignment]
883
898
  processor.enqueue_span(trodo_span)
@@ -59,6 +59,13 @@ class ManagedPrompt:
59
59
  response_format: Optional[ResponseFormat] = None
60
60
  variables: List[PromptVariable] = field(default_factory=list)
61
61
  version: int = 0
62
+ #: Git-style commit id of this version (content + parent + message + author
63
+ #: + time). Stable and immutable — the recommended way to reference a version.
64
+ version_hash: Optional[str] = None
65
+ #: sha256 of the content; identical content shares it.
66
+ content_hash: Optional[str] = None
67
+ #: The preceding version's ``version_hash`` (None for the first).
68
+ parent_hash: Optional[str] = None
62
69
  labels: List[str] = field(default_factory=list)
63
70
  tags: List[str] = field(default_factory=list)
64
71
  description: Optional[str] = None
@@ -66,6 +73,10 @@ class ManagedPrompt:
66
73
  #: True when this came from the ``fallback`` argument because the API was
67
74
  #: unreachable and nothing was cached.
68
75
  is_fallback: bool = False
76
+ #: The deploy label this fetch followed (for prompt traceability). Set by
77
+ #: PromptManager.get(); not part of the wire contract. ``repr=False`` /
78
+ #: ``compare=False`` so it never affects equality or reprs.
79
+ trace_label: Optional[str] = field(default=None, repr=False, compare=False)
69
80
 
70
81
  def compile(self, variables: Optional[Dict[str, Any]] = None, **kwargs: Any) -> CompiledPrompt:
71
82
  """Compile with variable values, as a dict or as keyword arguments."""
@@ -73,7 +84,25 @@ class ManagedPrompt:
73
84
 
74
85
  values = dict(variables or {})
75
86
  values.update(kwargs)
76
- return compile_prompt(self, values)
87
+ compiled = compile_prompt(self, values)
88
+ # Prompt traceability: record the exact version compiled so any span
89
+ # emitted in the surrounding wrap_agent/span scope carries it. No-op
90
+ # outside a run context. Never records a fallback (no real hash).
91
+ if not self.is_fallback:
92
+ try:
93
+ from ..otel.prompt_trace import record_compiled_prompt
94
+
95
+ record_compiled_prompt(
96
+ {
97
+ "name": self.name,
98
+ "version_hash": self.version_hash,
99
+ "content_hash": self.content_hash,
100
+ "label": self.trace_label,
101
+ }
102
+ )
103
+ except Exception: # noqa: BLE001
104
+ pass # traceability must never break compile
105
+ return compiled
77
106
 
78
107
 
79
108
  @dataclass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.13.0
3
+ Version: 2.15.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
@@ -7,6 +7,7 @@ tests/test_end_run.py
7
7
  tests/test_error_enrichment.py
8
8
  tests/test_llm_usage_cost.py
9
9
  tests/test_processor_methods.py
10
+ tests/test_prompt_trace.py
10
11
  tests/test_prompts.py
11
12
  tests/test_register_otel.py
12
13
  tests/test_start_run.py
@@ -30,6 +31,7 @@ trodo/otel/auto_instrument.py
30
31
  trodo/otel/context.py
31
32
  trodo/otel/helpers.py
32
33
  trodo/otel/processor.py
34
+ trodo/otel/prompt_trace.py
33
35
  trodo/otel/register.py
34
36
  trodo/otel/transport.py
35
37
  trodo/otel/wrap_agent.py
File without changes
File without changes