trodo-python 2.24.0__py3-none-any.whl → 2.25.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.
trodo/__init__.py CHANGED
@@ -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
 
trodo/_version.py CHANGED
@@ -1 +1 @@
1
- SDK_VERSION = "2.24.0"
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)
trodo/otel/register.py CHANGED
@@ -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
 
trodo/prompts/types.py CHANGED
@@ -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
@@ -1,5 +1,5 @@
1
- trodo/__init__.py,sha256=ahX3Z2VcSDifpb6xg4RNUJzLe64Oka2TY2vNLey2f8M,27913
2
- trodo/_version.py,sha256=fSs1-zmlRM8V2BkYDf5P5v8U0xEG-QAN-jPcvTDYQLU,23
1
+ trodo/__init__.py,sha256=ftnDcQvvOi9VnJoynkzzZCNwqHRhC7pbXS836bTwizo,27913
2
+ trodo/_version.py,sha256=50fFgmf3lAeDxqh-LvVujTqoWvZoG7fTVAY5gKvc-yQ,23
3
3
  trodo/client.py,sha256=6k6HeKrGV2720kK17--u6BjbhAUFZNUzpd9yEYiaQOs,24137
4
4
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
5
5
  trodo/user_context.py,sha256=uHCI2WYoOI3cNwdIUEuZdX4VYSu14pzm348ksn6hn_c,8195
@@ -20,18 +20,18 @@ trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8p
20
20
  trodo/managers/prompt_manager.py,sha256=qFVHgh7PW-SlgHp62ybirdYsJBg-fcQVMTTU3jPqFvo,13435
21
21
  trodo/managers/user_manager.py,sha256=faJYX3CHrD7ulYiShV7FhThSMX9aJ3kdOgU4Qtdy5FM,2844
22
22
  trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
23
- trodo/otel/auto_instrument.py,sha256=I_F3NCvc0JML2UpX9PaxFM3OGzMSuEYYfxZYTwkQDuo,26224
23
+ trodo/otel/auto_instrument.py,sha256=M5YVpFoPqn9bQohDFM7yyADrVGJPqFBToJLnBO7nOSA,29029
24
24
  trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
25
25
  trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
26
26
  trodo/otel/processor.py,sha256=WgtLdXT9gpR4TZ_9iuoX-kBP4UN4nelCtoDnogtW_Qc,8289
27
- trodo/otel/prompt_trace.py,sha256=d2A6UhlWAgHZLIKtU0NxgGUpHk_z3F2Nm4u4QTAC3tA,7332
28
- trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
27
+ trodo/otel/prompt_trace.py,sha256=-kywRBU0Q2gWntS9HU-P5i7Kc5U-IJU7UVLjkdC2jw4,13593
28
+ trodo/otel/register.py,sha256=7X3AfFQ5YvHGOUmdq8J38LQGvOJFPD907yT3uAp90lI,9607
29
29
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
30
30
  trodo/otel/wrap_agent.py,sha256=qx2jnuVIMZTv-PpRt-qFWqMWz1YO4jqY5pwgBQirVDo,44519
31
31
  trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
32
32
  trodo/prompts/compile.py,sha256=XoKDKz6Yaofhrk-87AVW-SnwbiXLvfp161AL2bAFzNY,8802
33
33
  trodo/prompts/template.py,sha256=eGO2ZVNOw6JK7Ffj1UCYDMJFYyX5qQW2POlmFJSQpAo,9975
34
- trodo/prompts/types.py,sha256=OYpaLi1H9pJjeq0gsJo8eb-bWoF8rw2u_7pdukudtqo,5258
34
+ trodo/prompts/types.py,sha256=Mjn6x6f7dI3TIVHSvzM2xs_gchuh5sCwUfBsW-YLVm0,5654
35
35
  trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
36
  trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
37
37
  trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
@@ -40,7 +40,7 @@ trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrT
40
40
  trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
41
41
  trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
42
42
  trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
43
- trodo_python-2.24.0.dist-info/METADATA,sha256=zDBPYuJWTElYQQgr1S8qeDzl1chRfwbNtESdc-PFe1M,25308
44
- trodo_python-2.24.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
45
- trodo_python-2.24.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
- trodo_python-2.24.0.dist-info/RECORD,,
43
+ trodo_python-2.25.0.dist-info/METADATA,sha256=_e2LmqFYjaZQdhh6Nr-Uj9wr_f88ZYLZDlD17N431as,25308
44
+ trodo_python-2.25.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
45
+ trodo_python-2.25.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
+ trodo_python-2.25.0.dist-info/RECORD,,