trodo-python 2.24.0__tar.gz → 2.25.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 (73) hide show
  1. {trodo_python-2.24.0 → trodo_python-2.25.0}/PKG-INFO +1 -1
  2. {trodo_python-2.24.0 → trodo_python-2.25.0}/pyproject.toml +1 -1
  3. trodo_python-2.25.0/tests/test_prompt_fingerprint.py +198 -0
  4. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/__init__.py +1 -1
  5. trodo_python-2.25.0/trodo/_version.py +1 -0
  6. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/otel/auto_instrument.py +73 -2
  7. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/otel/prompt_trace.py +185 -1
  8. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/otel/register.py +8 -0
  9. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/prompts/types.py +6 -0
  10. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo_python.egg-info/PKG-INFO +1 -1
  11. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo_python.egg-info/SOURCES.txt +1 -0
  12. trodo_python-2.24.0/trodo/_version.py +0 -1
  13. {trodo_python-2.24.0 → trodo_python-2.25.0}/README.md +0 -0
  14. {trodo_python-2.24.0 → trodo_python-2.25.0}/setup.cfg +0 -0
  15. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_anon_distinct_id.py +0 -0
  16. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_auto_instrument_fixes.py +0 -0
  17. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_cross_process_session.py +0 -0
  18. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_datasets.py +0 -0
  19. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_end_run.py +0 -0
  20. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_error_enrichment.py +0 -0
  21. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_experiments.py +0 -0
  22. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_framework_deference.py +0 -0
  23. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_get_prompt_version_types.py +0 -0
  24. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_instrumentor_resolution.py +0 -0
  25. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_llm_usage_cost.py +0 -0
  26. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_multi_agent_shape.py +0 -0
  27. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_processor_methods.py +0 -0
  28. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_prompt_trace.py +0 -0
  29. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_prompts.py +0 -0
  30. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_register_otel.py +0 -0
  31. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_sender.py +0 -0
  32. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_start_run.py +0 -0
  33. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_user_traits.py +0 -0
  34. {trodo_python-2.24.0 → trodo_python-2.25.0}/tests/test_wrap_agent_unchanged.py +0 -0
  35. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/api/__init__.py +0 -0
  36. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/api/_retry.py +0 -0
  37. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/api/async_client.py +0 -0
  38. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/api/endpoints.py +0 -0
  39. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/api/http_client.py +0 -0
  40. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/auto/__init__.py +0 -0
  41. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/auto/auto_event_manager.py +0 -0
  42. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/client.py +0 -0
  43. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/delivery/__init__.py +0 -0
  44. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/delivery/sender.py +0 -0
  45. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/managers/__init__.py +0 -0
  46. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/managers/dataset_manager.py +0 -0
  47. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/managers/experiment_manager.py +0 -0
  48. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/managers/group_manager.py +0 -0
  49. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/managers/people_manager.py +0 -0
  50. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/managers/prompt_manager.py +0 -0
  51. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/managers/user_manager.py +0 -0
  52. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/otel/__init__.py +0 -0
  53. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/otel/context.py +0 -0
  54. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/otel/helpers.py +0 -0
  55. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/otel/processor.py +0 -0
  56. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/otel/transport.py +0 -0
  57. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/otel/wrap_agent.py +0 -0
  58. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/prompts/__init__.py +0 -0
  59. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/prompts/compile.py +0 -0
  60. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/prompts/template.py +0 -0
  61. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/queue/__init__.py +0 -0
  62. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/queue/batch_flusher.py +0 -0
  63. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/queue/event_queue.py +0 -0
  64. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/session/__init__.py +0 -0
  65. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/session/server_session.py +0 -0
  66. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/session/session_manager.py +0 -0
  67. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/types.py +0 -0
  68. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/user_context.py +0 -0
  69. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/util/__init__.py +0 -0
  70. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo/util/lru.py +0 -0
  71. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  72. {trodo_python-2.24.0 → trodo_python-2.25.0}/trodo_python.egg-info/requires.txt +0 -0
  73. {trodo_python-2.24.0 → trodo_python-2.25.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.24.0
3
+ Version: 2.25.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.24.0"
7
+ version = "2.25.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,198 @@
1
+ """Exact-match prompt linking for AUTO-INSTRUMENTED provider spans.
2
+
3
+ Mirrors the Node promptFingerprint.test.ts. compile() before the run plus a
4
+ provider call made by an auto-instrumentor leaves no run context at compile
5
+ time and no carrier on the span's serialised input; compile() therefore
6
+ registers a content fingerprint, and a model span whose input has identical
7
+ (role, text) content is linked. Exact or nothing.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from types import SimpleNamespace
12
+
13
+ import pytest
14
+
15
+ from trodo.managers.prompt_manager import PromptManager
16
+ from trodo.otel import prompt_trace as pt
17
+ from trodo.otel.auto_instrument import _OtelAdapter
18
+ from trodo.otel.context import ActiveSpanContext, run_with_context
19
+
20
+ RUN_ID = "11111111-1111-1111-1111-111111111111"
21
+ ROOT = "22222222-2222-2222-2222-222222222222"
22
+
23
+ WIRE = {
24
+ "name": "refund-agent",
25
+ "version": 3,
26
+ "version_hash": "a3f9c2e8d1b04756",
27
+ "content_hash": "ccccontent1234",
28
+ "labels": ["production"],
29
+ "tags": [],
30
+ "messages": [
31
+ {"role": "system", "content": [{"type": "text", "text": "You help with {{q}}"}]},
32
+ {"role": "user", "content": [{"type": "text", "text": "Hello {{q}}"}]},
33
+ ],
34
+ "model": {"provider": "openai", "model": "gpt-4o"},
35
+ "tools": [],
36
+ "response_format": None,
37
+ "variables": [{"name": "q", "type": "string"}],
38
+ }
39
+
40
+
41
+ class PromptHttp:
42
+ def __init__(self, fail=False):
43
+ self.fail = fail
44
+
45
+ def get_prompt(self, name, label=None, version=None):
46
+ if self.fail:
47
+ raise RuntimeError("unreachable")
48
+ return {"prompt": dict(WIRE)}
49
+
50
+
51
+ @pytest.fixture(autouse=True)
52
+ def _clean():
53
+ pt._reset_fingerprints_for_tests()
54
+ yield
55
+ pt._reset_fingerprints_for_tests()
56
+
57
+
58
+ def provider_span(messages, extra=None):
59
+ attrs = {
60
+ "trodo.run_id": RUN_ID,
61
+ "trodo.parent_span_id": ROOT,
62
+ "gen_ai.request.model": "gpt-4o",
63
+ "gen_ai.system": "openai",
64
+ **(extra or {}),
65
+ }
66
+ for i, m in enumerate(messages):
67
+ attrs[f"gen_ai.prompt.{i}.role"] = m["role"]
68
+ attrs[f"gen_ai.prompt.{i}.content"] = m["content"]
69
+ span_context = SimpleNamespace(span_id=0xABCD1234ABCD1234)
70
+ return SimpleNamespace(
71
+ name="openai.chat",
72
+ get_span_context=lambda: span_context,
73
+ attributes=attrs,
74
+ start_time=1_000_000_000,
75
+ end_time=2_000_000_000,
76
+ status=None,
77
+ parent=None,
78
+ )
79
+
80
+
81
+ def end_span(otel):
82
+ captured = []
83
+ proc = SimpleNamespace(enqueue_span=captured.append)
84
+ _OtelAdapter(proc).on_end(otel)
85
+ return captured[0] if captured else None
86
+
87
+
88
+ def test_links_after_compile_before_run_with_identical_messages():
89
+ p = PromptManager(PromptHttp()).get("refund-agent", cache_ttl_seconds=0)
90
+ p.compile(q="my order") # no run, no span
91
+ span = end_span(provider_span([
92
+ {"role": "system", "content": "You help with my order"},
93
+ {"role": "user", "content": "Hello my order"},
94
+ ]))
95
+ assert span.attributes["trodo.prompt.name"] == "refund-agent"
96
+ assert span.attributes["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
97
+ assert span.attributes["trodo.prompt.label"] == "production"
98
+
99
+
100
+ def test_matches_parts_arrays_and_anthropic_system_shape():
101
+ p = PromptManager(PromptHttp()).get("refund-agent", cache_ttl_seconds=0)
102
+ compiled = p.compile(q="x")
103
+ assert pt.prompt_ref_from_fingerprint(compiled.messages)["name"] == "refund-agent"
104
+ assert pt.prompt_ref_from_fingerprint({
105
+ "system": "You help with x",
106
+ "messages": [{"role": "user", "content": [{"type": "text", "text": "Hello x"}]}],
107
+ })["name"] == "refund-agent"
108
+
109
+
110
+ def test_different_messages_do_not_link():
111
+ p = PromptManager(PromptHttp()).get("refund-agent", cache_ttl_seconds=0)
112
+ p.compile(q="my order")
113
+ span = end_span(provider_span([
114
+ {"role": "system", "content": "You help with my order"},
115
+ {"role": "user", "content": "Hello someone else"},
116
+ ]))
117
+ assert "trodo.prompt.name" not in (span.attributes or {})
118
+
119
+
120
+ def test_fallback_is_never_linked():
121
+ mgr = PromptManager(PromptHttp(fail=True))
122
+ p = mgr.get(
123
+ "refund-agent",
124
+ cache_ttl_seconds=0,
125
+ fallback={"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}], "variables": []},
126
+ )
127
+ assert p.is_fallback
128
+ p.compile()
129
+ assert pt._fingerprint_count_for_tests() == 0
130
+ span = end_span(provider_span([{"role": "user", "content": "hi"}]))
131
+ assert "trodo.prompt.name" not in (span.attributes or {})
132
+
133
+
134
+ def test_run_level_list_gets_the_prompt_via_on_start_state():
135
+ p = PromptManager(PromptHttp()).get("refund-agent", cache_ttl_seconds=0)
136
+ p.compile(q="a")
137
+ state = pt.new_prompt_state()
138
+ otel = provider_span([
139
+ {"role": "system", "content": "You help with a"},
140
+ {"role": "user", "content": "Hello a"},
141
+ ])
142
+ otel.set_attribute = lambda k, v: None
143
+ captured = []
144
+ adapter = _OtelAdapter(SimpleNamespace(enqueue_span=captured.append))
145
+ ctx = ActiveSpanContext(
146
+ run_id=RUN_ID, span_id=ROOT, parent_span_id=None,
147
+ team_site_id="site-x", processor=None, prompt_state=state,
148
+ )
149
+ with run_with_context(ctx):
150
+ adapter.on_start(otel)
151
+ adapter.on_end(otel)
152
+ assert captured[0].attributes["trodo.prompt.name"] == "refund-agent"
153
+ assert [r["name"] for r in state["all"]] == ["refund-agent"]
154
+
155
+
156
+ def test_ttl_expiry(monkeypatch):
157
+ t = [1_000_000.0]
158
+ monkeypatch.setattr(pt, "_now", lambda: t[0])
159
+ ref = {"name": "p", "version_hash": "h1"}
160
+ msgs = [{"role": "user", "content": "hello"}]
161
+ pt.register_compiled_messages(msgs, ref)
162
+ assert pt.prompt_ref_from_fingerprint(msgs) == ref
163
+ t[0] += pt.FINGERPRINT_TTL_SECONDS + 1
164
+ assert pt.prompt_ref_from_fingerprint(msgs) is None
165
+ assert pt._fingerprint_count_for_tests() == 0
166
+
167
+
168
+ def test_lru_eviction_beyond_cap():
169
+ for i in range(pt.FINGERPRINT_MAX_ENTRIES + 1):
170
+ pt.register_compiled_messages(
171
+ [{"role": "user", "content": f"m{i}"}], {"name": f"p{i}", "version_hash": f"h{i}"}
172
+ )
173
+ assert pt._fingerprint_count_for_tests() == pt.FINGERPRINT_MAX_ENTRIES
174
+ assert pt.prompt_ref_from_fingerprint([{"role": "user", "content": "m0"}]) is None
175
+ assert pt.prompt_ref_from_fingerprint([{"role": "user", "content": "m1"}])["name"] == "p1"
176
+
177
+
178
+ def test_most_recent_compile_wins_on_identical_content():
179
+ msgs = [{"role": "user", "content": "same"}]
180
+ pt.register_compiled_messages(msgs, {"name": "p", "version_hash": "old"})
181
+ pt.register_compiled_messages(msgs, {"name": "p", "version_hash": "new"})
182
+ assert pt.prompt_ref_from_fingerprint(msgs)["version_hash"] == "new"
183
+ assert pt._fingerprint_count_for_tests() == 1
184
+
185
+
186
+ def test_no_work_when_nothing_compiled(monkeypatch):
187
+ def boom(_):
188
+ raise AssertionError("should not fingerprint")
189
+
190
+ monkeypatch.setattr(pt, "fingerprint_messages", boom)
191
+ assert pt.prompt_ref_from_fingerprint([{"role": "user", "content": "x"}]) is None
192
+
193
+
194
+ def test_role_and_text_exact():
195
+ a = pt.fingerprint_messages([{"role": "user", "content": "x"}])
196
+ assert a != pt.fingerprint_messages([{"role": "assistant", "content": "x"}])
197
+ assert a != pt.fingerprint_messages([{"role": "user", "content": "x "}])
198
+ assert a == pt.fingerprint_messages('[{"role":"user","content":"x"}]')
@@ -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.24.0"
44
+ __version__ = "2.25.0"
45
45
 
46
46
  from typing import Any, Callable, Dict, List, Optional, Union
47
47
 
@@ -0,0 +1 @@
1
+ SDK_VERSION = "2.25.0"
@@ -14,7 +14,11 @@ 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
+ from .prompt_trace import (
18
+ prompt_attributes,
19
+ prompt_ref_from_fingerprint,
20
+ remember_prompt,
21
+ )
18
22
  from .processor import TrodoSpan, TrodoSpanProcessor
19
23
 
20
24
  # Always-on, one-shot warnings. Silent skips (missing opentelemetry-sdk) are the
@@ -313,7 +317,7 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
313
317
  if temperature is None:
314
318
  temperature = attrs.get("ai.settings.temperature")
315
319
 
316
- return TrodoSpan(
320
+ trodo_span = TrodoSpan(
317
321
  span_id=span_id,
318
322
  run_id=run_id,
319
323
  parent_span_id=parent_span_id,
@@ -338,6 +342,26 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
338
342
  output=completion,
339
343
  attributes=attrs or None,
340
344
  )
345
+ # Exact-match prompt link for auto-instrumented model calls. compile()
346
+ # before the run leaves no run context and no carrier on this serialised
347
+ # input, so compare the input's content fingerprint with what compile()
348
+ # registered. Overrides the on_start "most recent in this run" guess, which
349
+ # an exact match is strictly better than. No match changes nothing.
350
+ if kind == "llm" and prompt:
351
+ ref = prompt_ref_from_fingerprint(prompt)
352
+ if ref:
353
+ trodo_span.attributes = {**(trodo_span.attributes or {}), **prompt_attributes(ref)}
354
+ state = _span_prompt_state.pop(id(otel_span), None)
355
+ if state is None and ctx is not None:
356
+ state = getattr(ctx, "prompt_state", None)
357
+ remember_prompt(state, ref)
358
+ return trodo_span
359
+
360
+
361
+ # Run prompt state captured at on_start (contextvars are often gone by on_end),
362
+ # keyed by id(span) and bounded so spans that never end cannot leak.
363
+ _SPAN_STATE_MAX = 1024
364
+ _span_prompt_state: "Dict[int, Any]" = {}
341
365
 
342
366
 
343
367
  # Subclass the REAL SpanProcessor when the sdk is importable (it always is on
@@ -385,11 +409,16 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
385
409
  current = state.get("current") if isinstance(state, dict) else None
386
410
  for k, v in prompt_attributes(current).items():
387
411
  set_attr(k, v)
412
+ if isinstance(state, dict):
413
+ if len(_span_prompt_state) >= _SPAN_STATE_MAX:
414
+ _span_prompt_state.pop(next(iter(_span_prompt_state)), None)
415
+ _span_prompt_state[id(span)] = state
388
416
  except Exception:
389
417
  pass # never break user code
390
418
 
391
419
  def on_end(self, span: Any) -> None:
392
420
  trodo_span = otel_span_to_trodo_span(span)
421
+ _span_prompt_state.pop(id(span), None)
393
422
  if trodo_span is not None:
394
423
  self._processor.enqueue_span(trodo_span)
395
424
 
@@ -401,6 +430,48 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
401
430
  return True
402
431
 
403
432
 
433
+ class OtlpPromptLinker(_SpanProcessorBase): # type: ignore[valid-type,misc]
434
+ """OTLP mode: link provider spans to a managed prompt by exact fingerprint.
435
+
436
+ In OTLP mode finished spans go straight to the exporter, so there is no
437
+ TrodoSpan to enrich. ``on_ending`` (opentelemetry-sdk >= 1.43) is the last
438
+ point where the span is still writable. Register this BEFORE the batch
439
+ processor. On older SDKs ``on_ending`` is never called and this is inert.
440
+ """
441
+
442
+ def __init__(self) -> None:
443
+ try:
444
+ super().__init__()
445
+ except TypeError:
446
+ pass
447
+
448
+ def on_start(self, span: Any, parent_context: Any = None) -> None:
449
+ return None
450
+
451
+ def on_ending(self, span: Any) -> None:
452
+ try:
453
+ attrs = dict(getattr(span, "attributes", None) or {})
454
+ if _infer_kind(attrs) != "llm":
455
+ return
456
+ inp = _extract_input(attrs)
457
+ ref = prompt_ref_from_fingerprint(inp) if inp else None
458
+ if not ref:
459
+ return
460
+ for k, v in prompt_attributes(ref).items():
461
+ span.set_attribute(k, v)
462
+ except Exception:
463
+ pass # never break the export pipeline
464
+
465
+ def on_end(self, span: Any) -> None:
466
+ return None
467
+
468
+ def shutdown(self) -> None:
469
+ return None
470
+
471
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
472
+ return True
473
+
474
+
404
475
  def _instrument(module_name: str, *preferred: str) -> None:
405
476
  """Import ``module_name`` and call ``instrument()`` on its Instrumentor.
406
477
 
@@ -9,13 +9,25 @@ moved to a different version.
9
9
  The link is best-effort and never raises: if there is no active run context
10
10
  (the prompt was compiled outside ``wrap_agent``), recording is a silent no-op.
11
11
 
12
+ A third route covers compile-before-run followed by a model call made by an
13
+ AUTO-INSTRUMENTED provider SDK (no run context at compile time, no carrier on
14
+ the span's serialised input): compile() registers a sha256 fingerprint of the
15
+ rendered (role, text) list in a small bounded registry, and a model span whose
16
+ input normalises to the same fingerprint is linked. Exact match or nothing --
17
+ never a "last compiled prompt wins" guess. Only hashes are retained, never
18
+ message text. Providers that rewrite the messages will not match, by design.
19
+
12
20
  Mirrors ``sdks/trodo-node-sdk/src/otel/promptTrace.ts``.
13
21
  """
14
22
 
15
23
  from __future__ import annotations
16
24
 
25
+ import hashlib
26
+ import json
27
+ import threading
28
+ import time
17
29
  from collections import OrderedDict
18
- from typing import Any, Dict, List, Optional
30
+ from typing import Any, Dict, List, Optional, Tuple
19
31
 
20
32
  from .context import get_active_context
21
33
 
@@ -176,3 +188,175 @@ def _stamp_active_otel_span(ref: PromptRef) -> None:
176
188
  span.set_attribute(k, v)
177
189
  except Exception: # noqa: BLE001
178
190
  pass # not in OTLP mode / opentelemetry not installed — no-op
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Content-fingerprint registry
195
+ # ---------------------------------------------------------------------------
196
+
197
+ #: Max fingerprints kept process-wide (LRU). Keep in sync with the Node SDK.
198
+ FINGERPRINT_MAX_ENTRIES = 256
199
+ #: How long a fingerprint stays matchable. Keep in sync with the Node SDK.
200
+ FINGERPRINT_TTL_SECONDS = 10 * 60
201
+
202
+ # fingerprint -> (ref, registered_at). Insertion order == age order.
203
+ _fingerprints: "OrderedDict[str, Tuple[PromptRef, float]]" = OrderedDict()
204
+ _fp_lock = threading.Lock()
205
+
206
+
207
+ def _now() -> float:
208
+ return time.time()
209
+
210
+
211
+ def _text_of_content(content: Any) -> Optional[str]:
212
+ if isinstance(content, str):
213
+ return content
214
+ if not isinstance(content, list):
215
+ return None
216
+ out = ""
217
+ for part in content:
218
+ if isinstance(part, str):
219
+ out += part
220
+ elif isinstance(part, dict):
221
+ # {type:'text', text} (OpenAI/Anthropic/compiled), {content} (OTel GenAI parts)
222
+ if isinstance(part.get("text"), str):
223
+ out += part["text"]
224
+ elif part.get("type") == "text" and isinstance(part.get("content"), str):
225
+ out += part["content"]
226
+ return out
227
+
228
+
229
+ def _normalise_message(m: Any) -> Optional[Tuple[str, str]]:
230
+ if not isinstance(m, dict):
231
+ return None
232
+ # OpenInference indexed messages nest as {message: {role, content}}.
233
+ if isinstance(m.get("message"), dict):
234
+ m = m["message"]
235
+ role = m.get("role")
236
+ if not isinstance(role, str):
237
+ return None
238
+ content = m.get("content")
239
+ text = _text_of_content(content if content is not None else m.get("parts"))
240
+ if text is None:
241
+ return None
242
+ role = role.lower()
243
+ return ("system" if role == "developer" else role, text)
244
+
245
+
246
+ def normalise_messages(value: Any) -> Optional[List[Tuple[str, str]]]:
247
+ """Normalise a provider request's messages into a list of (role, text).
248
+
249
+ Handles an OpenAI-style list, Anthropic ``{system, messages}`` (system
250
+ becomes a leading 'system' message), a CompiledPrompt, and the JSON string
251
+ form provider instrumentations record. None when not message-shaped.
252
+ """
253
+ v = value
254
+ if hasattr(v, "messages") and not isinstance(v, (dict, list, str)):
255
+ v = getattr(v, "messages") # CompiledPrompt
256
+ if isinstance(v, (bytes, bytearray)):
257
+ return None
258
+ if isinstance(v, str):
259
+ t = v.strip()
260
+ if not t or t[0] not in "[{":
261
+ return None
262
+ try:
263
+ v = json.loads(t)
264
+ except Exception: # noqa: BLE001
265
+ return None
266
+ out: List[Tuple[str, str]] = []
267
+ if isinstance(v, dict):
268
+ items = v.get("messages")
269
+ if not isinstance(items, list):
270
+ return None
271
+ if v.get("system") is not None:
272
+ sys_text = _text_of_content(v["system"])
273
+ if sys_text is not None:
274
+ out.append(("system", sys_text))
275
+ elif isinstance(v, list):
276
+ items = v
277
+ else:
278
+ return None
279
+ for m in items:
280
+ n = _normalise_message(m)
281
+ if n is None:
282
+ return None # any non-message item: not a messages list, no guessing
283
+ out.append(n)
284
+ return out or None
285
+
286
+
287
+ def fingerprint_messages(value: Any) -> Optional[str]:
288
+ """sha256 over the normalised (role, text) list; length-prefixed."""
289
+ norm = normalise_messages(value)
290
+ if norm is None:
291
+ return None
292
+ h = hashlib.sha256()
293
+ for role, text in norm:
294
+ rb = text.encode("utf-8", "surrogatepass")
295
+ h.update(f"{len(role)}:{role}|{len(rb)}:".encode("utf-8"))
296
+ h.update(rb)
297
+ h.update(b";")
298
+ return h.hexdigest()
299
+
300
+
301
+ def _prune(now: float) -> None:
302
+ while _fingerprints:
303
+ key = next(iter(_fingerprints))
304
+ if now - _fingerprints[key][1] > FINGERPRINT_TTL_SECONDS:
305
+ del _fingerprints[key]
306
+ else:
307
+ break # insertion order == age order, so the rest are younger
308
+
309
+
310
+ def register_compiled_messages(messages: Any, ref: PromptRef) -> None:
311
+ """Register the rendered messages of a just-compiled prompt.
312
+
313
+ Best-effort, never raises, retains only a hash. Two prompt versions that
314
+ render byte-identical content collide on purpose (the messages cannot tell
315
+ them apart); the most recent compile wins.
316
+ """
317
+ try:
318
+ if not ref or not ref.get("name"):
319
+ return
320
+ fp = fingerprint_messages(messages)
321
+ if fp is None:
322
+ return
323
+ now = _now()
324
+ with _fp_lock:
325
+ _prune(now)
326
+ _fingerprints.pop(fp, None)
327
+ _fingerprints[fp] = (ref, now)
328
+ while len(_fingerprints) > FINGERPRINT_MAX_ENTRIES:
329
+ _fingerprints.popitem(last=False)
330
+ except Exception: # noqa: BLE001
331
+ pass # traceability must never break compile
332
+
333
+
334
+ def prompt_ref_from_fingerprint(value: Any) -> Optional[PromptRef]:
335
+ """Exact-match lookup for a provider span's input.
336
+
337
+ Zero cost when nothing has been compiled: the input is never parsed.
338
+ """
339
+ if not _fingerprints or value is None:
340
+ return None
341
+ try:
342
+ with _fp_lock:
343
+ _prune(_now())
344
+ if not _fingerprints:
345
+ return None
346
+ fp = fingerprint_messages(value)
347
+ if fp is None:
348
+ return None
349
+ with _fp_lock:
350
+ hit = _fingerprints.get(fp)
351
+ return hit[0] if hit else None
352
+ except Exception: # noqa: BLE001
353
+ return None
354
+
355
+
356
+ def _reset_fingerprints_for_tests() -> None:
357
+ with _fp_lock:
358
+ _fingerprints.clear()
359
+
360
+
361
+ def _fingerprint_count_for_tests() -> int:
362
+ return len(_fingerprints)
@@ -44,6 +44,12 @@ _registered: Optional[RegisterOtelResult] = None
44
44
  _otlp_processor: Optional[Any] = None
45
45
 
46
46
 
47
+ def _prompt_linker() -> Any:
48
+ from .auto_instrument import OtlpPromptLinker
49
+
50
+ return OtlpPromptLinker()
51
+
52
+
47
53
  def flush_otlp_pipeline() -> None:
48
54
  """Force-flush the 'otlp'-mode export pipeline. No-op in 'trodo' mode."""
49
55
  if _otlp_processor is not None:
@@ -172,6 +178,7 @@ def _setup_otlp_mode(
172
178
  if existing_class not in ("ProxyTracerProvider", "NoOpTracerProvider"):
173
179
  # User's pipeline owns the provider — just attach our processor.
174
180
  if hasattr(existing, "add_span_processor"):
181
+ existing.add_span_processor(_prompt_linker())
175
182
  existing.add_span_processor(batch_processor)
176
183
  # Auto-instrumentations are presumably already wired by the existing
177
184
  # pipeline. We still set the transport so wrap_agent dispatches.
@@ -189,6 +196,7 @@ def _setup_otlp_mode(
189
196
  "trodo.sdk.mode": "otlp",
190
197
  })
191
198
  provider = TracerProvider(resource=resource)
199
+ provider.add_span_processor(_prompt_linker())
192
200
  provider.add_span_processor(batch_processor)
193
201
  otel_trace.set_tracer_provider(provider)
194
202
 
@@ -95,6 +95,7 @@ class ManagedPrompt:
95
95
  try:
96
96
  from ..otel.prompt_trace import (
97
97
  record_compiled_prompt,
98
+ register_compiled_messages,
98
99
  tag_carrier_with_prompt,
99
100
  )
100
101
 
@@ -113,6 +114,11 @@ class ManagedPrompt:
113
114
  # since callers pass one or the other.
114
115
  tag_carrier_with_prompt(compiled, ref)
115
116
  tag_carrier_with_prompt(getattr(compiled, "messages", None), ref)
117
+ # (3) a content fingerprint, so an auto-instrumented provider
118
+ # span handed exactly these messages (compiled before the
119
+ # run; no carrier on the serialised input) links by exact
120
+ # match.
121
+ register_compiled_messages(getattr(compiled, "messages", None), ref)
116
122
  except Exception: # noqa: BLE001
117
123
  pass # traceability must never break compile
118
124
  return compiled
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.24.0
3
+ Version: 2.25.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
@@ -13,6 +13,7 @@ tests/test_instrumentor_resolution.py
13
13
  tests/test_llm_usage_cost.py
14
14
  tests/test_multi_agent_shape.py
15
15
  tests/test_processor_methods.py
16
+ tests/test_prompt_fingerprint.py
16
17
  tests/test_prompt_trace.py
17
18
  tests/test_prompts.py
18
19
  tests/test_register_otel.py
@@ -1 +0,0 @@
1
- SDK_VERSION = "2.24.0"
File without changes
File without changes