trodo-python 2.21.0__py3-none-any.whl → 2.23.0__py3-none-any.whl

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.
@@ -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
 
trodo/otel/wrap_agent.py CHANGED
@@ -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
trodo/prompts/compile.py CHANGED
@@ -36,6 +36,36 @@ def _q(value: Any) -> str:
36
36
  except (TypeError, ValueError):
37
37
  return repr(value)
38
38
 
39
+ def _js_string(value: Any) -> str:
40
+ """JavaScript ``String()`` semantics, byte-for-byte.
41
+
42
+ A non-string passed for a string-typed variable is a caller type mismatch,
43
+ but all three engines must agree on what it renders as -- the backend
44
+ reference (which powers the playground) and the Node SDK both go through
45
+ JS ``String()``, so ``True`` must render ``true``, ``3.0`` must render
46
+ ``3``, a list joins with commas, and a dict renders the infamous
47
+ ``[object Object]``. The JSON rendering callers actually want lives on the
48
+ ``json`` variable type, where all three engines already emit compact JSON.
49
+ """
50
+ if value is None:
51
+ return ""
52
+ if isinstance(value, bool):
53
+ return "true" if value else "false"
54
+ if isinstance(value, float) and value.is_integer() and abs(value) < 1e21:
55
+ return str(int(value))
56
+ if isinstance(value, (int, float)):
57
+ return str(value)
58
+ if isinstance(value, str):
59
+ return value
60
+ if isinstance(value, (list, tuple)):
61
+ # Array.prototype.toString: elements joined by ',', null/undefined
62
+ # rendering empty, recursively.
63
+ return ",".join(_js_string(x) for x in value)
64
+ if isinstance(value, dict):
65
+ return "[object Object]"
66
+ return str(value)
67
+
68
+
39
69
  def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
40
70
  """Coerce a caller value to the declared type.
41
71
 
@@ -71,7 +101,7 @@ def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
71
101
  return value
72
102
 
73
103
  if type_ == "string":
74
- return value if isinstance(value, str) else str(value)
104
+ return value if isinstance(value, str) else _js_string(value)
75
105
 
76
106
  if type_ == "messages":
77
107
  if not isinstance(value, list):
@@ -119,7 +149,13 @@ def build_scope(
119
149
  # empty. That IS the point of declaring a default -- there is no third
120
150
  # case where the caller has to have supplied something, and adding one
121
151
  # would turn an empty render into a crash for no gain.
122
- if not has or value is None:
152
+ #
153
+ # The default applies to an ABSENT key only. An explicit ``None`` is a
154
+ # value -- "render this empty" -- exactly as ``null`` is in the backend
155
+ # reference engine and the Node SDK. This engine used to substitute the
156
+ # default for an explicit ``None``, so the same call rendered different
157
+ # text in Python than everywhere else, including the playground.
158
+ if not has:
123
159
  value = default if has_default else ([] if type_ == "messages" else "")
124
160
 
125
161
  scope[name] = _coerce(value, type_, name, errors)
trodo/prompts/template.py CHANGED
@@ -159,6 +159,21 @@ def _lookup(path: str, scopes: Sequence[Any]) -> Tuple[bool, Any]:
159
159
  return False, None
160
160
 
161
161
 
162
+ def _jsonable(value):
163
+ """Normalise for JSON.stringify parity: JS has one number type, so a float
164
+ that is a whole number must serialise as ``1`` and not ``1.0`` -- at any
165
+ depth. Everything else passes through untouched."""
166
+ if isinstance(value, bool):
167
+ return value
168
+ if isinstance(value, float) and value.is_integer() and abs(value) < 1e21:
169
+ return int(value)
170
+ if isinstance(value, dict):
171
+ return {k: _jsonable(v) for k, v in value.items()}
172
+ if isinstance(value, (list, tuple)):
173
+ return [_jsonable(v) for v in value]
174
+ return value
175
+
176
+
162
177
  def _stringify(value: Any) -> str:
163
178
  if value is None:
164
179
  return ""
@@ -173,7 +188,7 @@ def _stringify(value: Any) -> str:
173
188
  return str(int(value))
174
189
  return str(value)
175
190
  try:
176
- return json.dumps(value, separators=(",", ":"))
191
+ return json.dumps(_jsonable(value), separators=(",", ":"), ensure_ascii=False)
177
192
  except (TypeError, ValueError):
178
193
  return str(value)
179
194
 
trodo/prompts/types.py CHANGED
@@ -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.23.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
@@ -16,18 +16,18 @@ trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8p
16
16
  trodo/managers/prompt_manager.py,sha256=qFVHgh7PW-SlgHp62ybirdYsJBg-fcQVMTTU3jPqFvo,13435
17
17
  trodo/managers/user_manager.py,sha256=faJYX3CHrD7ulYiShV7FhThSMX9aJ3kdOgU4Qtdy5FM,2844
18
18
  trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
19
- trodo/otel/auto_instrument.py,sha256=Iae9A9lvh2PImE6gqnyEMXeRPRpjTxZcxx1zW2KDLac,21467
19
+ trodo/otel/auto_instrument.py,sha256=kcfajnmaVuX_giLYBr2P7GiWk-lzZt-iniY8rJEALYw,21819
20
20
  trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
21
21
  trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
22
22
  trodo/otel/processor.py,sha256=LKlXxP3BeQ7DP8SzYAgXZZOeJ6-6b7e43i19gCUC_0Y,7939
23
- trodo/otel/prompt_trace.py,sha256=BIrdLOpsR1_HoaCmWb_706GwZSs-UG3p76fjvX3CX3w,3391
23
+ trodo/otel/prompt_trace.py,sha256=X-8AIfwMmegOZl9yMifUkpYu1rV4wE6-kuYYtts0wgA,6184
24
24
  trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
25
25
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
26
- trodo/otel/wrap_agent.py,sha256=ipMKpdgRF5ibgjDlt2yTBPqbwb8Hq30C7o3hms0QAdU,41987
26
+ trodo/otel/wrap_agent.py,sha256=Izty1jxF4ANVqS72L0CR9jmMuW372AApaEO_0LC5Thw,43860
27
27
  trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
28
- trodo/prompts/compile.py,sha256=fAYq55LdGIvglNVCnBF3YXyM8W9raLku1M8OAzaQExE,7135
29
- trodo/prompts/template.py,sha256=obQiivPCR6LEdz7q1cby7uL25tYHYui4aoEaiUNqWfs,9357
30
- trodo/prompts/types.py,sha256=A24njy6qcc8QNzWEfGu28BbBr360S7A5Pw7_ObIWNS4,4684
28
+ trodo/prompts/compile.py,sha256=XoKDKz6Yaofhrk-87AVW-SnwbiXLvfp161AL2bAFzNY,8802
29
+ trodo/prompts/template.py,sha256=eGO2ZVNOw6JK7Ffj1UCYDMJFYyX5qQW2POlmFJSQpAo,9975
30
+ trodo/prompts/types.py,sha256=OYpaLi1H9pJjeq0gsJo8eb-bWoF8rw2u_7pdukudtqo,5258
31
31
  trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
33
33
  trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
@@ -36,7 +36,7 @@ trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrT
36
36
  trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
37
37
  trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
38
38
  trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
39
- trodo_python-2.21.0.dist-info/METADATA,sha256=La6v97ZyzSSTa0CeVffS53LhsUvF29mDpm6i92YO63Q,25308
40
- trodo_python-2.21.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
41
- trodo_python-2.21.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
42
- trodo_python-2.21.0.dist-info/RECORD,,
39
+ trodo_python-2.23.0.dist-info/METADATA,sha256=HklaG2r4SZNydUnzdRUluF9KLh1d-IszAlS1Sr4UEFI,25308
40
+ trodo_python-2.23.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
41
+ trodo_python-2.23.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
42
+ trodo_python-2.23.0.dist-info/RECORD,,